feat: create workspace from loading existing exported file (#2122)
Co-authored-by: Himself65 <himself65@outlook.com>
This commit is contained in:
6
.github/workflows/build.yml
vendored
6
.github/workflows/build.yml
vendored
@@ -47,7 +47,7 @@ jobs:
|
|||||||
uses: ./.github/actions/setup-node
|
uses: ./.github/actions/setup-node
|
||||||
- name: Build Electron
|
- name: Build Electron
|
||||||
working-directory: apps/electron
|
working-directory: apps/electron
|
||||||
run: yarn exec ts-node-esm ./scripts/build-ci.mts
|
run: yarn build-layers
|
||||||
- name: Upload Ubuntu desktop artifact
|
- name: Upload Ubuntu desktop artifact
|
||||||
uses: actions/upload-artifact@v3
|
uses: actions/upload-artifact@v3
|
||||||
with:
|
with:
|
||||||
@@ -298,6 +298,10 @@ jobs:
|
|||||||
name: next-js-static
|
name: next-js-static
|
||||||
path: ./apps/electron/resources/web-static
|
path: ./apps/electron/resources/web-static
|
||||||
|
|
||||||
|
- name: Rebuild Electron dependences
|
||||||
|
run: yarn rebuild:for-electron
|
||||||
|
working-directory: apps/electron
|
||||||
|
|
||||||
- name: Run desktop tests
|
- name: Run desktop tests
|
||||||
run: xvfb-run --auto-servernum --server-args="-screen 0 1280x960x24" -- yarn test
|
run: xvfb-run --auto-servernum --server-args="-screen 0 1280x960x24" -- yarn test
|
||||||
working-directory: apps/electron
|
working-directory: apps/electron
|
||||||
|
|||||||
11
.vscode/settings.json
vendored
11
.vscode/settings.json
vendored
@@ -29,5 +29,14 @@
|
|||||||
"rust-analyzer.linkedProjects": ["packages/octobase-node/Cargo.toml"],
|
"rust-analyzer.linkedProjects": ["packages/octobase-node/Cargo.toml"],
|
||||||
"[typescriptreact]": {
|
"[typescriptreact]": {
|
||||||
"editor.defaultFormatter": "esbenp.prettier-vscode"
|
"editor.defaultFormatter": "esbenp.prettier-vscode"
|
||||||
}
|
},
|
||||||
|
"vitest.include": [
|
||||||
|
"packages/**/*.spec.ts",
|
||||||
|
"packages/**/*.spec.tsx",
|
||||||
|
"apps/web/**/*.spec.ts",
|
||||||
|
"apps/web/**/*.spec.tsx",
|
||||||
|
"apps/electron/layers/**/*.spec.ts",
|
||||||
|
"tests/unit/**/*.spec.ts",
|
||||||
|
"tests/unit/**/*.spec.tsx"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -45,6 +45,7 @@ module.exports = {
|
|||||||
teamId: process.env.APPLE_TEAM_ID,
|
teamId: process.env.APPLE_TEAM_ID,
|
||||||
}
|
}
|
||||||
: undefined,
|
: undefined,
|
||||||
|
// do we need the following line?
|
||||||
extraResource: ['./resources/app-update.yml'],
|
extraResource: ['./resources/app-update.yml'],
|
||||||
},
|
},
|
||||||
makers: [
|
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 { app } from 'electron';
|
||||||
import path from 'path';
|
|
||||||
|
|
||||||
export const appContext = {
|
export const appContext = {
|
||||||
appName: app.name,
|
get appName() {
|
||||||
appDataPath: path.join(app.getPath('appData'), app.name),
|
return app.name;
|
||||||
|
},
|
||||||
|
get appDataPath() {
|
||||||
|
return app.getPath('sessionData');
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
export type AppContext = typeof appContext;
|
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 fs from 'fs-extra';
|
||||||
import * as Y from 'yjs';
|
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 = [
|
const schemas = [
|
||||||
`CREATE TABLE IF NOT EXISTS "updates" (
|
`CREATE TABLE IF NOT EXISTS "updates" (
|
||||||
@@ -33,46 +34,68 @@ interface BlobRow {
|
|||||||
timestamp: string;
|
timestamp: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class WorkspaceDatabase {
|
const SQLITE_ORIGIN = Symbol('sqlite-origin');
|
||||||
sqliteDB: Database;
|
|
||||||
|
export class WorkspaceSQLiteDB {
|
||||||
|
db: Database;
|
||||||
ydoc = new Y.Doc();
|
ydoc = new Y.Doc();
|
||||||
firstConnect = false;
|
firstConnect = false;
|
||||||
|
lastUpdateTime = ts();
|
||||||
|
|
||||||
constructor(public path: string) {
|
constructor(public path: string, public workspaceId: string) {
|
||||||
this.sqliteDB = this.reconnectDB();
|
this.db = this.reconnectDB();
|
||||||
}
|
}
|
||||||
|
|
||||||
// release resources
|
// release resources
|
||||||
destroy = () => {
|
destroy = () => {
|
||||||
this.sqliteDB?.close();
|
this.db?.close();
|
||||||
this.ydoc.destroy();
|
this.ydoc.destroy();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
getWorkspaceName = () => {
|
||||||
|
return this.ydoc.getMap('space:meta').get('name') as string;
|
||||||
|
};
|
||||||
|
|
||||||
reconnectDB = () => {
|
reconnectDB = () => {
|
||||||
logger.log('open db', this.path);
|
logger.log('open db', this.workspaceId);
|
||||||
if (this.sqliteDB) {
|
if (this.db) {
|
||||||
this.sqliteDB.close();
|
this.db.close();
|
||||||
}
|
}
|
||||||
|
|
||||||
// use cached version?
|
// use cached version?
|
||||||
const db = (this.sqliteDB = sqlite(this.path));
|
const db = (this.db = sqlite(this.path));
|
||||||
db.exec(schemas.join(';'));
|
db.exec(schemas.join(';'));
|
||||||
|
|
||||||
if (!this.firstConnect) {
|
if (!this.firstConnect) {
|
||||||
this.ydoc.on('update', this.addUpdateToSQLite);
|
this.ydoc.on('update', (update: Uint8Array, origin) => {
|
||||||
|
if (origin !== SQLITE_ORIGIN) {
|
||||||
|
this.addUpdateToSQLite(update);
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Y.transact(this.ydoc, () => {
|
||||||
const updates = this.getUpdates();
|
const updates = this.getUpdates();
|
||||||
updates.forEach(update => {
|
updates.forEach(update => {
|
||||||
Y.applyUpdate(this.ydoc, update.data);
|
// 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;
|
this.firstConnect = true;
|
||||||
|
|
||||||
return db;
|
return db;
|
||||||
};
|
};
|
||||||
|
|
||||||
getEncodedDocUpdates = () => {
|
getDocAsUpdates = () => {
|
||||||
return Y.encodeStateAsUpdate(this.ydoc);
|
return Y.encodeStateAsUpdate(this.ydoc);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -80,18 +103,23 @@ export class WorkspaceDatabase {
|
|||||||
// after that, the update is added to the db
|
// after that, the update is added to the db
|
||||||
applyUpdate = (data: Uint8Array) => {
|
applyUpdate = (data: Uint8Array) => {
|
||||||
Y.applyUpdate(this.ydoc, data);
|
Y.applyUpdate(this.ydoc, data);
|
||||||
|
|
||||||
// todo: trim the updates when the number of records is too large
|
// todo: trim the updates when the number of records is too large
|
||||||
// 1. store the current ydoc state in the db
|
// 1. store the current ydoc state in the db
|
||||||
// 2. then delete the old updates
|
// 2. then delete the old updates
|
||||||
// yjs-idb will always trim the db for the first time after DB is loaded
|
// 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) => {
|
addBlob = (key: string, data: Uint8Array) => {
|
||||||
|
this.lastUpdateTime = ts();
|
||||||
try {
|
try {
|
||||||
const statement = this.sqliteDB.prepare(
|
const statement = this.db.prepare(
|
||||||
'INSERT INTO blobs (key, data) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET data = ?'
|
'INSERT INTO blobs (key, data) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET data = ?'
|
||||||
);
|
);
|
||||||
statement.run(key, data, data);
|
statement.run(key, data, data);
|
||||||
|
return key;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('addBlob', error);
|
logger.error('addBlob', error);
|
||||||
}
|
}
|
||||||
@@ -99,9 +127,7 @@ export class WorkspaceDatabase {
|
|||||||
|
|
||||||
getBlob = (key: string) => {
|
getBlob = (key: string) => {
|
||||||
try {
|
try {
|
||||||
const statement = this.sqliteDB.prepare(
|
const statement = this.db.prepare('SELECT data FROM blobs WHERE key = ?');
|
||||||
'SELECT data FROM blobs WHERE key = ?'
|
|
||||||
);
|
|
||||||
const row = statement.get(key) as BlobRow;
|
const row = statement.get(key) as BlobRow;
|
||||||
if (!row) {
|
if (!row) {
|
||||||
return null;
|
return null;
|
||||||
@@ -114,10 +140,9 @@ export class WorkspaceDatabase {
|
|||||||
};
|
};
|
||||||
|
|
||||||
deleteBlob = (key: string) => {
|
deleteBlob = (key: string) => {
|
||||||
|
this.lastUpdateTime = ts();
|
||||||
try {
|
try {
|
||||||
const statement = this.sqliteDB.prepare(
|
const statement = this.db.prepare('DELETE FROM blobs WHERE key = ?');
|
||||||
'DELETE FROM blobs WHERE key = ?'
|
|
||||||
);
|
|
||||||
statement.run(key);
|
statement.run(key);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('deleteBlob', error);
|
logger.error('deleteBlob', error);
|
||||||
@@ -126,7 +151,7 @@ export class WorkspaceDatabase {
|
|||||||
|
|
||||||
getPersistentBlobKeys = () => {
|
getPersistentBlobKeys = () => {
|
||||||
try {
|
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[];
|
const rows = statement.all() as BlobRow[];
|
||||||
return rows.map(row => row.key);
|
return rows.map(row => row.key);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -137,7 +162,7 @@ export class WorkspaceDatabase {
|
|||||||
|
|
||||||
private getUpdates = () => {
|
private getUpdates = () => {
|
||||||
try {
|
try {
|
||||||
const statement = this.sqliteDB.prepare('SELECT * FROM updates');
|
const statement = this.db.prepare('SELECT * FROM updates');
|
||||||
const rows = statement.all() as UpdateRow[];
|
const rows = statement.all() as UpdateRow[];
|
||||||
return rows;
|
return rows;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -150,25 +175,57 @@ export class WorkspaceDatabase {
|
|||||||
private addUpdateToSQLite = (data: Uint8Array) => {
|
private addUpdateToSQLite = (data: Uint8Array) => {
|
||||||
try {
|
try {
|
||||||
const start = performance.now();
|
const start = performance.now();
|
||||||
const statement = this.sqliteDB.prepare(
|
const statement = this.db.prepare(
|
||||||
'INSERT INTO updates (data) VALUES (?)'
|
'INSERT INTO updates (data) VALUES (?)'
|
||||||
);
|
);
|
||||||
statement.run(data);
|
statement.run(data);
|
||||||
logger.debug('addUpdateToSQLite', performance.now() - start, 'ms');
|
logger.debug(
|
||||||
|
'addUpdateToSQLite',
|
||||||
|
this.workspaceId,
|
||||||
|
'length:',
|
||||||
|
data.length,
|
||||||
|
performance.now() - start,
|
||||||
|
'ms'
|
||||||
|
);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('addUpdateToSQLite', error);
|
logger.error('addUpdateToSQLite', error);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function openWorkspaceDatabase(
|
export async function getWorkspaceDBPath(
|
||||||
context: AppContext,
|
context: AppContext,
|
||||||
workspaceId: string
|
workspaceId: string
|
||||||
) {
|
) {
|
||||||
const basePath = path.join(context.appDataPath, 'workspaces', workspaceId);
|
const basePath = path.join(context.appDataPath, 'workspaces', workspaceId);
|
||||||
// hmmm.... blocking api but it should be fine, right?
|
|
||||||
await fs.ensureDir(basePath);
|
await fs.ensureDir(basePath);
|
||||||
const dbPath = path.join(basePath, 'storage.db');
|
return path.join(basePath, 'storage.db');
|
||||||
|
}
|
||||||
return new WorkspaceDatabase(dbPath);
|
|
||||||
|
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 './security-restrictions';
|
||||||
|
|
||||||
import { app } from 'electron';
|
import { app } from 'electron';
|
||||||
import path from 'path';
|
|
||||||
|
|
||||||
import { logger } from '../../logger';
|
import { registerEvents } from './events';
|
||||||
import { registerHandlers } from './handlers';
|
import { registerHandlers } from './handlers';
|
||||||
|
import { registerUpdater } from './handlers/updater';
|
||||||
|
import { logger } from './logger';
|
||||||
import { restoreOrCreateWindow } from './main-window';
|
import { restoreOrCreateWindow } from './main-window';
|
||||||
import { registerProtocol } from './protocol';
|
import { registerProtocol } from './protocol';
|
||||||
import { registerUpdater } from './updater';
|
|
||||||
|
|
||||||
if (require('electron-squirrel-startup')) app.exit();
|
// allow tests to overwrite app name through passing args
|
||||||
if (process.defaultApp) {
|
if (process.argv.includes('--app-name')) {
|
||||||
if (process.argv.length >= 2) {
|
const appNameIndex = process.argv.indexOf('--app-name');
|
||||||
app.setAsDefaultProtocolClient('affine', process.execPath, [
|
const appName = process.argv[appNameIndex + 1];
|
||||||
path.resolve(process.argv[1]),
|
app.setName(appName);
|
||||||
]);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
app.setAsDefaultProtocolClient('affine');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Prevent multiple instances
|
* Prevent multiple instances
|
||||||
*/
|
*/
|
||||||
@@ -58,6 +55,7 @@ app
|
|||||||
.whenReady()
|
.whenReady()
|
||||||
.then(registerProtocol)
|
.then(registerProtocol)
|
||||||
.then(registerHandlers)
|
.then(registerHandlers)
|
||||||
|
.then(registerEvents)
|
||||||
.then(restoreOrCreateWindow)
|
.then(restoreOrCreateWindow)
|
||||||
.then(registerUpdater)
|
.then(registerUpdater)
|
||||||
.catch(e => console.error('Failed create window:', e));
|
.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 electronWindowState from 'electron-window-state';
|
||||||
import { join } from 'path';
|
import { join } from 'path';
|
||||||
|
|
||||||
import { logger } from '../../logger';
|
|
||||||
import { isMacOS } from '../../utils';
|
import { isMacOS } from '../../utils';
|
||||||
|
import { logger } from './logger';
|
||||||
|
|
||||||
const IS_DEV: boolean =
|
const IS_DEV: boolean =
|
||||||
process.env.NODE_ENV === 'development' && !process.env.CI;
|
process.env.NODE_ENV === 'development' && !process.env.CI;
|
||||||
@@ -60,7 +60,9 @@ async function createWindow() {
|
|||||||
logger.info('main window is ready to show');
|
logger.info('main window is ready to show');
|
||||||
|
|
||||||
if (DEV_TOOL) {
|
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
|
const pageUrl = process.env.DEV_SERVER_URL || 'file://./index.html'; // see protocol.ts
|
||||||
|
|
||||||
|
logger.info('loading page at', pageUrl);
|
||||||
|
|
||||||
await browserWindow.loadURL(pageUrl);
|
await browserWindow.loadURL(pageUrl);
|
||||||
|
|
||||||
logger.info('main window is loaded at' + pageUrl);
|
logger.info('main window is loaded at', pageUrl);
|
||||||
|
|
||||||
return browserWindow;
|
return browserWindow;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -33,7 +33,6 @@ export function registerProtocol() {
|
|||||||
protocol.interceptFileProtocol('file', (request, callback) => {
|
protocol.interceptFileProtocol('file', (request, callback) => {
|
||||||
const url = request.url.replace(/^file:\/\//, '');
|
const url = request.url.replace(/^file:\/\//, '');
|
||||||
const realpath = toAbsolutePath(url);
|
const realpath = toAbsolutePath(url);
|
||||||
// console.log('realpath', realpath, 'for', url);
|
|
||||||
callback(realpath);
|
callback(realpath);
|
||||||
return true;
|
return true;
|
||||||
});
|
});
|
||||||
@@ -41,7 +40,6 @@ export function registerProtocol() {
|
|||||||
protocol.registerFileProtocol('assets', (request, callback) => {
|
protocol.registerFileProtocol('assets', (request, callback) => {
|
||||||
const url = request.url.replace(/^assets:\/\//, '');
|
const url = request.url.replace(/^assets:\/\//, '');
|
||||||
const realpath = toAbsolutePath(url);
|
const realpath = toAbsolutePath(url);
|
||||||
// console.log('realpath', realpath, 'for', url);
|
|
||||||
callback(realpath);
|
callback(realpath);
|
||||||
return true;
|
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 */
|
/* eslint-disable @typescript-eslint/consistent-type-imports */
|
||||||
|
|
||||||
interface Window {
|
interface Window {
|
||||||
apis: typeof import('./src/affine-apis').apis;
|
apis?: typeof import('./src/affine-apis').apis;
|
||||||
appInfo: typeof import('./src/affine-apis').appInfo;
|
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
|
// NOTE: we will generate preload types from this file
|
||||||
|
|
||||||
import { ipcRenderer } from 'electron';
|
import { ipcRenderer } from 'electron';
|
||||||
|
|
||||||
import type { MainEventMap } from '../../main-events';
|
import type { MainIPCEventMap, MainIPCHandlerMap } from '../../constraints';
|
||||||
|
|
||||||
// main -> renderer
|
type WithoutFirstParameter<T> = T extends (_: any, ...args: infer P) => infer R
|
||||||
function onMainEvent<T extends keyof MainEventMap>(
|
? (...args: P) => R
|
||||||
eventName: T,
|
: 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);
|
|
||||||
}
|
|
||||||
|
|
||||||
const apis = {
|
type HandlersMap<N extends keyof MainIPCHandlerMap> = {
|
||||||
db: {
|
[K in keyof MainIPCHandlerMap[N]]: WithoutFirstParameter<
|
||||||
// workspace providers
|
MainIPCHandlerMap[N][K]
|
||||||
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 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 = {
|
const appInfo = {
|
||||||
electron: true,
|
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('apis', affineApis.apis);
|
||||||
|
contextBridge.exposeInMainWorld('events', affineApis.events);
|
||||||
contextBridge.exposeInMainWorld('appInfo', affineApis.appInfo);
|
contextBridge.exposeInMainWorld('appInfo', affineApis.appInfo);
|
||||||
|
|||||||
@@ -11,15 +11,18 @@
|
|||||||
"homepage": "https://github.com/toeverything/AFFiNE",
|
"homepage": "https://github.com/toeverything/AFFiNE",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "yarn electron-rebuild && yarn cross-env DEV_SERVER_URL=http://localhost:8080 node scripts/dev.mjs",
|
"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",
|
"prod": "yarn electron-rebuild && yarn node scripts/dev.mjs",
|
||||||
|
"build-layers": "zx scripts/build-layers.mjs",
|
||||||
"generate-assets": "zx scripts/generate-assets.mjs",
|
"generate-assets": "zx scripts/generate-assets.mjs",
|
||||||
|
"generate-main-exposed-meta": "zx scripts/generate-main-exposed-meta.mjs",
|
||||||
"package": "electron-forge package",
|
"package": "electron-forge package",
|
||||||
"make": "electron-forge make",
|
"make": "electron-forge make",
|
||||||
"make-macos-arm64": "electron-forge make --platform=darwin --arch=arm64",
|
"make-macos-arm64": "electron-forge make --platform=darwin --arch=arm64",
|
||||||
"make-macos-x64": "electron-forge make --platform=darwin --arch=x64",
|
"make-macos-x64": "electron-forge make --platform=darwin --arch=x64",
|
||||||
"make-windows-x64": "electron-forge make --platform=win32 --arch=x64",
|
"make-windows-x64": "electron-forge make --platform=win32 --arch=x64",
|
||||||
"make-linux-x64": "electron-forge make --platform=linux --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",
|
"rebuild:for-electron": "yarn electron-rebuild",
|
||||||
"test": "playwright test"
|
"test": "playwright test"
|
||||||
},
|
},
|
||||||
@@ -55,7 +58,10 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"better-sqlite3": "^8.3.0",
|
"better-sqlite3": "^8.3.0",
|
||||||
|
"chokidar": "^3.5.3",
|
||||||
"electron-updater": "^5.3.0",
|
"electron-updater": "^5.3.0",
|
||||||
|
"nanoid": "^4.0.2",
|
||||||
|
"rxjs": "^7.8.1",
|
||||||
"yjs": "^13.6.1"
|
"yjs": "^13.6.1"
|
||||||
},
|
},
|
||||||
"build": {
|
"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 root = fileURLToPath(new URL('..', import.meta.url));
|
||||||
export const NODE_MAJOR_VERSION = 18;
|
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 = {
|
const nativeNodeModulesPlugin = {
|
||||||
name: 'native-node-modules',
|
name: 'native-node-modules',
|
||||||
setup(build) {
|
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}} */
|
/** @return {{main: import('esbuild').BuildOptions, preload: import('esbuild').BuildOptions}} */
|
||||||
export const config = () => {
|
export const config = () => {
|
||||||
const define = Object.fromEntries(
|
const define = Object.fromEntries([
|
||||||
ENV_MACROS.map(key => [
|
...ENV_MACROS.map(key => [
|
||||||
'process.env.' + key,
|
'process.env.' + key,
|
||||||
JSON.stringify(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 {
|
return {
|
||||||
main: {
|
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'),
|
outdir: resolve(root, './dist/layers/main'),
|
||||||
bundle: true,
|
bundle: true,
|
||||||
target: `node${NODE_MAJOR_VERSION}`,
|
target: `node${NODE_MAJOR_VERSION}`,
|
||||||
platform: 'node',
|
platform: 'node',
|
||||||
external: ['electron', 'yjs', 'better-sqlite3'],
|
external: ['electron', 'yjs', 'better-sqlite3', 'electron-updater'],
|
||||||
plugins: [nativeNodeModulesPlugin],
|
plugins: [nativeNodeModulesPlugin],
|
||||||
define: define,
|
define: define,
|
||||||
|
format: 'cjs',
|
||||||
},
|
},
|
||||||
preload: {
|
preload: {
|
||||||
entryPoints: [resolve(root, './layers/preload/src/index.ts')],
|
entryPoints: [resolve(root, './layers/preload/src/index.ts')],
|
||||||
@@ -43,7 +60,8 @@ export const config = () => {
|
|||||||
bundle: true,
|
bundle: true,
|
||||||
target: `node${NODE_MAJOR_VERSION}`,
|
target: `node${NODE_MAJOR_VERSION}`,
|
||||||
platform: 'node',
|
platform: 'node',
|
||||||
external: ['electron'],
|
external: ['electron', '../main/exposed-meta'],
|
||||||
|
plugins: [nativeNodeModulesPlugin],
|
||||||
define: define,
|
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 { readFileSync } from 'node:fs';
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
|
|
||||||
@@ -7,8 +8,8 @@ import * as esbuild from 'esbuild';
|
|||||||
|
|
||||||
import { config, root } from './common.mjs';
|
import { config, root } from './common.mjs';
|
||||||
|
|
||||||
/** @type 'production' | 'development'' */
|
// this means we don't spawn electron windows, mainly for testing
|
||||||
const mode = (process.env.NODE_ENV = process.env.NODE_ENV || 'development');
|
const watchMode = process.argv.includes('--watch');
|
||||||
|
|
||||||
/** Messages on stderr that match any of the contained patterns will be stripped from output */
|
/** Messages on stderr that match any of the contained patterns will be stripped from output */
|
||||||
const stderrFilterPatterns = [
|
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} */
|
/** @type {ChildProcessWithoutNullStreams | null} */
|
||||||
let spawnProcess = null;
|
let spawnProcess = null;
|
||||||
|
|
||||||
function spawnOrReloadElectron() {
|
function spawnOrReloadElectron() {
|
||||||
|
if (watchMode) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (spawnProcess !== null) {
|
if (spawnProcess !== null) {
|
||||||
spawnProcess.off('exit', process.exit);
|
spawnProcess.off('exit', process.exit);
|
||||||
spawnProcess.kill('SIGINT');
|
spawnProcess.kill('SIGINT');
|
||||||
@@ -59,77 +59,80 @@ function spawnOrReloadElectron() {
|
|||||||
console.error(data);
|
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);
|
spawnProcess.on('exit', process.exit);
|
||||||
}
|
}
|
||||||
|
|
||||||
const common = config();
|
const common = config();
|
||||||
|
|
||||||
async function main() {
|
function watchPreload() {
|
||||||
async function watchPreload(onInitialBuild) {
|
return new Promise(async resolve => {
|
||||||
|
let initialBuild = false;
|
||||||
const preloadBuild = await esbuild.context({
|
const preloadBuild = await esbuild.context({
|
||||||
...common.preload,
|
...common.preload,
|
||||||
plugins: [
|
plugins: [
|
||||||
...(common.preload.plugins ?? []),
|
...(common.preload.plugins ?? []),
|
||||||
{
|
{
|
||||||
name: 'affine-dev:reload-app-on-preload-change',
|
name: 'electron-dev:reload-app-on-preload-change',
|
||||||
setup(build) {
|
setup(build) {
|
||||||
let initialBuild = false;
|
|
||||||
build.onEnd(() => {
|
build.onEnd(() => {
|
||||||
if (initialBuild) {
|
if (initialBuild) {
|
||||||
console.log(`[preload] has changed`);
|
console.log(`[preload] has changed, [re]launching electron...`);
|
||||||
spawnOrReloadElectron();
|
spawnOrReloadElectron();
|
||||||
} else {
|
} else {
|
||||||
|
resolve();
|
||||||
initialBuild = true;
|
initialBuild = true;
|
||||||
onInitialBuild();
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
|
// watch will trigger build.onEnd() on first run & on subsequent changes
|
||||||
await preloadBuild.watch();
|
await preloadBuild.watch();
|
||||||
}
|
});
|
||||||
|
}
|
||||||
|
|
||||||
async function watchMain() {
|
async function watchMain() {
|
||||||
const define = {
|
return new Promise(async resolve => {
|
||||||
...common.main.define,
|
let initialBuild = false;
|
||||||
'process.env.NODE_ENV': `"${mode}"`,
|
|
||||||
};
|
|
||||||
|
|
||||||
if (DEV_SERVER_URL) {
|
|
||||||
define['process.env.DEV_SERVER_URL'] = `"${DEV_SERVER_URL}"`;
|
|
||||||
}
|
|
||||||
|
|
||||||
const mainBuild = await esbuild.context({
|
const mainBuild = await esbuild.context({
|
||||||
...common.main,
|
...common.main,
|
||||||
define: define,
|
|
||||||
plugins: [
|
plugins: [
|
||||||
...(common.main.plugins ?? []),
|
...(common.main.plugins ?? []),
|
||||||
{
|
{
|
||||||
name: 'affine-dev:reload-app-on-main-change',
|
name: 'electron-dev:reload-app-on-main-change',
|
||||||
setup(build) {
|
setup(build) {
|
||||||
let initialBuild = false;
|
|
||||||
build.onEnd(() => {
|
build.onEnd(() => {
|
||||||
|
execSync('yarn generate-main-exposed-meta');
|
||||||
|
|
||||||
if (initialBuild) {
|
if (initialBuild) {
|
||||||
console.log(`[main] has changed, [re]launching electron...`);
|
console.log(`[main] has changed, [re]launching electron...`);
|
||||||
|
spawnOrReloadElectron();
|
||||||
} else {
|
} else {
|
||||||
|
resolve();
|
||||||
initialBuild = true;
|
initialBuild = true;
|
||||||
}
|
}
|
||||||
spawnOrReloadElectron();
|
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
await mainBuild.watch();
|
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();
|
main();
|
||||||
|
|||||||
@@ -3,10 +3,6 @@ import 'zx/globals';
|
|||||||
|
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
|
|
||||||
import * as esbuild from 'esbuild';
|
|
||||||
|
|
||||||
import { config } from './common.mjs';
|
|
||||||
|
|
||||||
const repoRootDir = path.join(__dirname, '..', '..', '..');
|
const repoRootDir = path.join(__dirname, '..', '..', '..');
|
||||||
const electronRootDir = path.join(__dirname, '..');
|
const electronRootDir = path.join(__dirname, '..');
|
||||||
const publicDistDir = path.join(electronRootDir, 'resources');
|
const publicDistDir = path.join(electronRootDir, 'resources');
|
||||||
@@ -37,8 +33,7 @@ if (process.platform === 'win32') {
|
|||||||
cd(repoRootDir);
|
cd(repoRootDir);
|
||||||
|
|
||||||
// step 1: build electron resources
|
// step 1: build electron resources
|
||||||
await buildLayers();
|
await $`yarn workspace @affine/electron build-layers`;
|
||||||
echo('Build layers done');
|
|
||||||
|
|
||||||
// step 2: build web (nextjs) dist
|
// step 2: build web (nextjs) dist
|
||||||
if (!process.env.SKIP_WEB_BUILD) {
|
if (!process.env.SKIP_WEB_BUILD) {
|
||||||
@@ -75,17 +70,3 @@ async function cleanup() {
|
|||||||
await fs.emptyDir(path.join(electronRootDir, 'layers', 'preload', 'dist'));
|
await fs.emptyDir(path.join(electronRootDir, 'layers', 'preload', 'dist'));
|
||||||
await fs.remove(path.join(electronRootDir, 'out'));
|
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 { expect } from '@playwright/test';
|
||||||
import type { ElectronApplication } from 'playwright';
|
|
||||||
import { _electron as electron } from 'playwright';
|
|
||||||
|
|
||||||
let electronApp: ElectronApplication;
|
import { test } from './fixture';
|
||||||
let page: Page;
|
|
||||||
|
|
||||||
test.beforeEach(async () => {
|
test('new page', async ({ page, workspace }) => {
|
||||||
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 () => {
|
|
||||||
await page.getByTestId('new-page-button').click({
|
await page.getByTestId('new-page-button').click({
|
||||||
delay: 100,
|
delay: 100,
|
||||||
});
|
});
|
||||||
await page.waitForSelector('v-line');
|
await page.waitForSelector('v-line');
|
||||||
const flavour = await page.evaluate(
|
const flavour = (await workspace.current()).flavour;
|
||||||
// @ts-expect-error
|
|
||||||
() => globalThis.currentWorkspace.flavour
|
|
||||||
);
|
|
||||||
expect(flavour).toBe('local');
|
expect(flavour).toBe('local');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('app theme', async () => {
|
test('app theme', async ({ page, electronApp }) => {
|
||||||
await page.waitForSelector('v-line');
|
await page.waitForSelector('v-line');
|
||||||
const root = page.locator('html');
|
const root = page.locator('html');
|
||||||
{
|
{
|
||||||
@@ -50,10 +19,16 @@ test('app theme', async () => {
|
|||||||
element.getAttribute('data-theme')
|
element.getAttribute('data-theme')
|
||||||
);
|
);
|
||||||
expect(themeMode).toBe('light');
|
expect(themeMode).toBe('light');
|
||||||
}
|
|
||||||
await page.screenshot({
|
// check if electron theme source is set to light
|
||||||
path: resolve(testResultDir, 'affine-light-theme-electron.png'),
|
const themeSource = await electronApp.evaluate(({ nativeTheme }) => {
|
||||||
|
return nativeTheme.themeSource;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
expect(themeSource).toBe('light');
|
||||||
|
}
|
||||||
|
|
||||||
|
{
|
||||||
await page.getByTestId('editor-option-menu').click();
|
await page.getByTestId('editor-option-menu').click();
|
||||||
await page.getByTestId('change-theme-dark').click();
|
await page.getByTestId('change-theme-dark').click();
|
||||||
await page.waitForTimeout(50);
|
await page.waitForTimeout(50);
|
||||||
@@ -63,12 +38,16 @@ test('app theme', async () => {
|
|||||||
);
|
);
|
||||||
expect(themeMode).toBe('dark');
|
expect(themeMode).toBe('dark');
|
||||||
}
|
}
|
||||||
await page.screenshot({
|
|
||||||
path: resolve(testResultDir, 'affine-dark-theme-electron.png'),
|
const themeSource = await electronApp.evaluate(({ nativeTheme }) => {
|
||||||
|
return nativeTheme.themeSource;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
expect(themeSource).toBe('dark');
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
test('affine cloud disabled', async () => {
|
test('affine cloud disabled', async ({ page }) => {
|
||||||
await page.getByTestId('new-page-button').click({
|
await page.getByTestId('new-page-button').click({
|
||||||
delay: 100,
|
delay: 100,
|
||||||
});
|
});
|
||||||
@@ -79,7 +58,8 @@ test('affine cloud disabled', async () => {
|
|||||||
state: 'visible',
|
state: 'visible',
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
test('affine onboarding button', async () => {
|
|
||||||
|
test('affine onboarding button', async ({ page }) => {
|
||||||
await page.getByTestId('help-island').click();
|
await page.getByTestId('help-island').click();
|
||||||
await page.getByTestId('easy-guide').click();
|
await page.getByTestId('easy-guide').click();
|
||||||
const onboardingModal = page.locator('[data-testid=onboarding-modal]');
|
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 { atom } from 'jotai';
|
||||||
import { atomWithStorage } from 'jotai/utils';
|
import { atomWithStorage } from 'jotai/utils';
|
||||||
|
|
||||||
|
import type { CreateWorkspaceMode } from '../components/affine/create-workspace-modal';
|
||||||
import { WorkspacePlugins } from '../plugins';
|
import { WorkspacePlugins } from '../plugins';
|
||||||
|
|
||||||
const logger = new DebugLogger('web:atoms');
|
const logger = new DebugLogger('web:atoms');
|
||||||
@@ -49,9 +50,9 @@ rootWorkspacesMetadataAtom.onMount = setAtom => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (environment.isDesktop) {
|
if (environment.isDesktop) {
|
||||||
window.apis.workspace.list().then(workspaceIDs => {
|
window.apis?.workspace.list().then(workspaceIDs => {
|
||||||
const newMetadata = workspaceIDs.map(w => ({
|
const newMetadata = workspaceIDs.map(w => ({
|
||||||
id: w,
|
id: w[0],
|
||||||
flavour: WorkspaceFlavour.LOCAL,
|
flavour: WorkspaceFlavour.LOCAL,
|
||||||
}));
|
}));
|
||||||
setAtom(metadata => {
|
setAtom(metadata => {
|
||||||
@@ -75,7 +76,7 @@ export const currentEditorAtom = rootCurrentEditorAtom;
|
|||||||
|
|
||||||
// modal atoms
|
// modal atoms
|
||||||
export const openWorkspacesModalAtom = atom(false);
|
export const openWorkspacesModalAtom = atom(false);
|
||||||
export const openCreateWorkspaceModalAtom = atom(false);
|
export const openCreateWorkspaceModalAtom = atom<CreateWorkspaceMode>(false);
|
||||||
export const openQuickSearchModalAtom = atom(false);
|
export const openQuickSearchModalAtom = atom(false);
|
||||||
export const openOnboardingModalAtom = 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',
|
userSelect: 'none',
|
||||||
margin: '20px 0',
|
margin: '20px 0',
|
||||||
a: {
|
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 { useIsWorkspaceOwner } from '../../../hooks/affine/use-is-workspace-owner';
|
||||||
import { fetcher, QueryKey } from '../../../plugins/affine/fetcher';
|
import { fetcher, QueryKey } from '../../../plugins/affine/fetcher';
|
||||||
import type { AffineOfficialWorkspace } from '../../../shared';
|
import type { AffineOfficialWorkspace } from '../../../shared';
|
||||||
|
import * as style from './index.css';
|
||||||
import { CollaborationPanel } from './panel/collaboration';
|
import { CollaborationPanel } from './panel/collaboration';
|
||||||
import { ExportPanel } from './panel/export';
|
import { ExportPanel } from './panel/export';
|
||||||
import { GeneralPanel } from './panel/general';
|
import { GeneralPanel } from './panel/general';
|
||||||
import { PublishPanel } from './panel/publish';
|
import { PublishPanel } from './panel/publish';
|
||||||
import { SyncPanel } from './panel/sync';
|
import { SyncPanel } from './panel/sync';
|
||||||
import {
|
|
||||||
StyledIndicator,
|
|
||||||
StyledSettingContainer,
|
|
||||||
StyledSettingContent,
|
|
||||||
StyledTabButtonWrapper,
|
|
||||||
WorkspaceSettingTagItem,
|
|
||||||
} from './style';
|
|
||||||
|
|
||||||
export type WorkspaceSettingDetailProps = {
|
export type WorkspaceSettingDetailProps = {
|
||||||
workspace: AffineOfficialWorkspace;
|
workspace: AffineOfficialWorkspace;
|
||||||
@@ -133,39 +127,43 @@ export const WorkspaceSettingDetail: React.FC<
|
|||||||
);
|
);
|
||||||
const Component = useMemo(() => panelMap[currentTab].ui, [currentTab]);
|
const Component = useMemo(() => panelMap[currentTab].ui, [currentTab]);
|
||||||
return (
|
return (
|
||||||
<StyledSettingContainer
|
<div
|
||||||
|
className={style.container}
|
||||||
aria-label="workspace-setting-detail"
|
aria-label="workspace-setting-detail"
|
||||||
ref={containerRef}
|
ref={containerRef}
|
||||||
>
|
>
|
||||||
<StyledTabButtonWrapper>
|
<div className={style.tabButtonWrapper}>
|
||||||
{Object.entries(panelMap).map(([key, value]) => {
|
{Object.entries(panelMap).map(([key, value]) => {
|
||||||
if ('enable' in value && !value.enable(workspace.flavour)) {
|
if ('enable' in value && !value.enable(workspace.flavour)) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
<WorkspaceSettingTagItem
|
<div
|
||||||
|
className={
|
||||||
|
style.tagItem[currentTab === key ? 'active' : 'inactive']
|
||||||
|
}
|
||||||
key={key}
|
key={key}
|
||||||
isActive={currentTab === key}
|
|
||||||
data-tab-key={key}
|
data-tab-key={key}
|
||||||
onClick={handleTabClick}
|
onClick={handleTabClick}
|
||||||
>
|
>
|
||||||
{t[value.name]()}
|
{t[value.name]()}
|
||||||
</WorkspaceSettingTagItem>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
<StyledIndicator
|
<div
|
||||||
|
className={style.indicator}
|
||||||
ref={ref => {
|
ref={ref => {
|
||||||
indicatorRef.current = ref;
|
indicatorRef.current = ref;
|
||||||
startTransaction();
|
startTransaction();
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</StyledTabButtonWrapper>
|
</div>
|
||||||
<StyledSettingContent>
|
<div className={style.content}>
|
||||||
{/* todo: add skeleton */}
|
{/* todo: add skeleton */}
|
||||||
<Suspense fallback="loading panel...">
|
<Suspense fallback="loading panel...">
|
||||||
<Component {...props} key={currentTab} data-tab-ui={currentTab} />
|
<Component {...props} key={currentTab} data-tab-ui={currentTab} />
|
||||||
</Suspense>
|
</Suspense>
|
||||||
</StyledSettingContent>
|
</div>
|
||||||
</StyledSettingContainer>
|
</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 { useAFFiNEI18N } from '@affine/i18n/hooks';
|
||||||
|
import { rootCurrentWorkspaceIdAtom } from '@affine/workspace/atom';
|
||||||
|
import { useAtomValue } from 'jotai';
|
||||||
|
|
||||||
export const ExportPanel = () => {
|
export const ExportPanel = () => {
|
||||||
|
const id = useAtomValue(rootCurrentWorkspaceIdAtom);
|
||||||
const t = useAFFiNEI18N();
|
const t = useAFFiNEI18N();
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -9,9 +12,12 @@ export const ExportPanel = () => {
|
|||||||
<Button
|
<Button
|
||||||
type="light"
|
type="light"
|
||||||
shape="circle"
|
shape="circle"
|
||||||
disabled={!environment.isDesktop}
|
disabled={!environment.isDesktop || !id}
|
||||||
onClick={() => {
|
data-testid="export-affine-backup"
|
||||||
window.apis.openSaveDBFileDialog();
|
onClick={async () => {
|
||||||
|
if (id && (await window.apis?.dialog.saveDBFileAs(id))) {
|
||||||
|
toast(t['Export success']());
|
||||||
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{t['Export AFFiNE backup file']()}
|
{t['Export AFFiNE backup file']()}
|
||||||
|
|||||||
@@ -37,10 +37,14 @@ export const WorkspaceDeleteModal = ({
|
|||||||
const t = useAFFiNEI18N();
|
const t = useAFFiNEI18N();
|
||||||
|
|
||||||
const handleDelete = useCallback(() => {
|
const handleDelete = useCallback(() => {
|
||||||
onDeleteWorkspace().then(() => {
|
onDeleteWorkspace()
|
||||||
|
.then(() => {
|
||||||
toast(t['Successfully deleted'](), {
|
toast(t['Successfully deleted'](), {
|
||||||
portal: document.body,
|
portal: document.body,
|
||||||
});
|
});
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
// ignore error
|
||||||
});
|
});
|
||||||
}, [onDeleteWorkspace, t]);
|
}, [onDeleteWorkspace, t]);
|
||||||
|
|
||||||
|
|||||||
@@ -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 { WorkspaceAvatar } from '@affine/component/workspace-avatar';
|
||||||
import { useAFFiNEI18N } from '@affine/i18n/hooks';
|
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 { useBlockSuiteWorkspaceAvatarUrl } from '@toeverything/hooks/use-block-suite-workspace-avatar-url';
|
||||||
import { useBlockSuiteWorkspaceName } from '@toeverything/hooks/use-block-suite-workspace-name';
|
import { useBlockSuiteWorkspaceName } from '@toeverything/hooks/use-block-suite-workspace-name';
|
||||||
|
import clsx from 'clsx';
|
||||||
import type React from 'react';
|
import type React from 'react';
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
|
|
||||||
import { useIsWorkspaceOwner } from '../../../../../hooks/affine/use-is-workspace-owner';
|
import { useIsWorkspaceOwner } from '../../../../../hooks/affine/use-is-workspace-owner';
|
||||||
import { Upload } from '../../../../pure/file-upload';
|
import { Upload } from '../../../../pure/file-upload';
|
||||||
import {
|
|
||||||
CloudWorkspaceIcon,
|
|
||||||
JoinedWorkspaceIcon,
|
|
||||||
LocalWorkspaceIcon,
|
|
||||||
} from '../../../../pure/icons';
|
|
||||||
import type { PanelProps } from '../../index';
|
import type { PanelProps } from '../../index';
|
||||||
import { StyledRow, StyledSettingKey } from '../../style';
|
import * as style from '../../index.css';
|
||||||
import { WorkspaceDeleteModal } from './delete';
|
import { WorkspaceDeleteModal } from './delete';
|
||||||
import { CameraIcon } from './icons';
|
import { CameraIcon } from './icons';
|
||||||
import { WorkspaceLeave } from './leave';
|
import { WorkspaceLeave } from './leave';
|
||||||
import {
|
import { StyledInput } from './style';
|
||||||
StyledAvatar,
|
|
||||||
StyledEditButton,
|
|
||||||
StyledInput,
|
|
||||||
StyledWorkspaceInfo,
|
|
||||||
} from './style';
|
|
||||||
|
|
||||||
export const GeneralPanel: React.FC<PanelProps> = ({
|
export const GeneralPanel: React.FC<PanelProps> = ({
|
||||||
workspace,
|
workspace,
|
||||||
@@ -37,11 +34,11 @@ export const GeneralPanel: React.FC<PanelProps> = ({
|
|||||||
);
|
);
|
||||||
const [input, setInput] = useState<string>(name);
|
const [input, setInput] = useState<string>(name);
|
||||||
const isOwner = useIsWorkspaceOwner(workspace);
|
const isOwner = useIsWorkspaceOwner(workspace);
|
||||||
const [showEditInput, setShowEditInput] = useState(false);
|
|
||||||
const t = useAFFiNEI18N();
|
const t = useAFFiNEI18N();
|
||||||
|
|
||||||
const handleUpdateWorkspaceName = (name: string) => {
|
const handleUpdateWorkspaceName = (name: string) => {
|
||||||
setName(name);
|
setName(name);
|
||||||
|
toast(t['Update workspace name success']());
|
||||||
};
|
};
|
||||||
|
|
||||||
const [, update] = useBlockSuiteWorkspaceAvatarUrl(
|
const [, update] = useBlockSuiteWorkspaceAvatarUrl(
|
||||||
@@ -49,9 +46,17 @@ export const GeneralPanel: React.FC<PanelProps> = ({
|
|||||||
);
|
);
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<StyledRow>
|
<div data-testid="avatar-row" className={style.row}>
|
||||||
<StyledSettingKey>{t['Workspace Avatar']()}</StyledSettingKey>
|
<div className={style.col}>
|
||||||
<StyledAvatar disabled={!isOwner}>
|
<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 ? (
|
{isOwner ? (
|
||||||
<Upload
|
<Upload
|
||||||
accept="image/gif,image/jpeg,image/jpg,image/png,image/svg"
|
accept="image/gif,image/jpeg,image/jpg,image/png,image/svg"
|
||||||
@@ -68,35 +73,25 @@ export const GeneralPanel: React.FC<PanelProps> = ({
|
|||||||
) : (
|
) : (
|
||||||
<WorkspaceAvatar size={72} workspace={workspace} />
|
<WorkspaceAvatar size={72} workspace={workspace} />
|
||||||
)}
|
)}
|
||||||
</StyledAvatar>
|
</div>
|
||||||
</StyledRow>
|
</div>
|
||||||
|
<div className={clsx(style.col)}></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<StyledRow>
|
<div data-testid="workspace-name-row" className={style.row}>
|
||||||
<StyledSettingKey>{t['Workspace Name']()}</StyledSettingKey>
|
<div className={style.col}>
|
||||||
|
<div className={style.settingItemLabel}>{t['Workspace Name']()}</div>
|
||||||
|
<div className={style.settingItemLabelHint}>
|
||||||
|
{t['Change workspace name hint']()}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div style={{ position: 'relative' }}>
|
<div className={style.col}>
|
||||||
<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
|
<StyledInput
|
||||||
width={284}
|
width={284}
|
||||||
height={38}
|
height={38}
|
||||||
value={input}
|
value={input}
|
||||||
|
data-testid="workspace-name-input"
|
||||||
placeholder={t['Workspace Name']()}
|
placeholder={t['Workspace Name']()}
|
||||||
maxLength={50}
|
maxLength={50}
|
||||||
minLength={0}
|
minLength={0}
|
||||||
@@ -104,102 +99,105 @@ export const GeneralPanel: React.FC<PanelProps> = ({
|
|||||||
setInput(newName);
|
setInput(newName);
|
||||||
}}
|
}}
|
||||||
></StyledInput>
|
></StyledInput>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={style.col}>
|
||||||
<Button
|
<Button
|
||||||
type="light"
|
type="light"
|
||||||
shape="circle"
|
size="middle"
|
||||||
style={{ marginLeft: '24px' }}
|
data-testid="save-workspace-name"
|
||||||
|
icon={<SaveIcon />}
|
||||||
disabled={input === workspace.blockSuiteWorkspace.meta.name}
|
disabled={input === workspace.blockSuiteWorkspace.meta.name}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
handleUpdateWorkspaceName(input);
|
handleUpdateWorkspaceName(input);
|
||||||
setShowEditInput(false);
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{t['Confirm']()}
|
{t['Save']()}
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
|
||||||
type="default"
|
|
||||||
shape="circle"
|
|
||||||
style={{ marginLeft: '24px' }}
|
|
||||||
onClick={() => {
|
|
||||||
setInput(workspace.blockSuiteWorkspace.meta.name ?? '');
|
|
||||||
setShowEditInput(false);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{t['Cancel']()}
|
|
||||||
</Button>
|
|
||||||
</FlexWrapper>
|
|
||||||
</MuiFade>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</StyledRow>
|
</div>
|
||||||
|
|
||||||
{/* fixme(himself65): how to know a workspace owner by api? */}
|
{environment.isDesktop && (
|
||||||
{/*{!isOwner && (*/}
|
<div className={style.row}>
|
||||||
{/* <StyledRow>*/}
|
<div className={style.col}>
|
||||||
{/* <StyledSettingKey>{t('Workspace Owner')}</StyledSettingKey>*/}
|
<div className={style.settingItemLabel}>
|
||||||
{/* <FlexWrapper alignItems="center">*/}
|
{t['Storage Folder']()}
|
||||||
{/* <MuiAvatar*/}
|
</div>
|
||||||
{/* sx={{ width: 72, height: 72, marginRight: '12px' }}*/}
|
<div className={style.settingItemLabelHint}>
|
||||||
{/* alt="owner avatar"*/}
|
{t['Storage Folder Hint']()}
|
||||||
{/* // src={currentWorkspace?.owner?.avatar}*/}
|
</div>
|
||||||
{/* >*/}
|
</div>
|
||||||
{/* <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
|
<div className={style.col}>
|
||||||
|
<div
|
||||||
|
className={style.storageTypeWrapper}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
if (environment.isDesktop) {
|
if (environment.isDesktop) {
|
||||||
window.apis.openDBFolder();
|
window.apis?.dialog.revealDBFile(workspace.id);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<StyledSettingKey>{t['Workspace Type']()}</StyledSettingKey>
|
<FolderIcon color="var(--affine-primary-color)" />
|
||||||
{isOwner ? (
|
<div className={style.storageTypeLabelWrapper}>
|
||||||
workspace.flavour === WorkspaceFlavour.LOCAL ? (
|
<div className={style.storageTypeLabel}>
|
||||||
<StyledWorkspaceInfo>
|
{t['Open folder']()}
|
||||||
<LocalWorkspaceIcon />
|
</div>
|
||||||
<span>{t['Local Workspace']()}</span>
|
<div className={style.storageTypeLabelHint}>
|
||||||
</StyledWorkspaceInfo>
|
{t['Open folder hint']()}
|
||||||
) : (
|
</div>
|
||||||
<StyledWorkspaceInfo>
|
</div>
|
||||||
<CloudWorkspaceIcon />
|
<ArrowRightSmallIcon color="var(--affine-primary-color)" />
|
||||||
<span>{t['Cloud Workspace']()}</span>
|
</div>
|
||||||
</StyledWorkspaceInfo>
|
|
||||||
)
|
|
||||||
) : (
|
|
||||||
<StyledWorkspaceInfo>
|
|
||||||
<JoinedWorkspaceIcon />
|
|
||||||
<span>{t['Joined Workspace']()}</span>
|
|
||||||
</StyledWorkspaceInfo>
|
|
||||||
)}
|
|
||||||
</StyledRow>
|
|
||||||
|
|
||||||
<StyledRow>
|
<div
|
||||||
<StyledSettingKey> {t['Delete Workspace']()}</StyledSettingKey>
|
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 ? (
|
{isOwner ? (
|
||||||
<>
|
<>
|
||||||
<Button
|
<Button
|
||||||
type="warning"
|
type="warning"
|
||||||
shape="circle"
|
|
||||||
style={{ borderRadius: '40px' }}
|
|
||||||
data-testid="delete-workspace-button"
|
data-testid="delete-workspace-button"
|
||||||
|
size="middle"
|
||||||
|
icon={<DeleteIcon />}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setShowDelete(true);
|
setShowDelete(true);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{t['Delete Workspace']()}
|
{t['Delete']()}
|
||||||
</Button>
|
</Button>
|
||||||
<WorkspaceDeleteModal
|
<WorkspaceDeleteModal
|
||||||
onDeleteWorkspace={onDeleteWorkspace}
|
onDeleteWorkspace={onDeleteWorkspace}
|
||||||
@@ -214,12 +212,12 @@ export const GeneralPanel: React.FC<PanelProps> = ({
|
|||||||
<>
|
<>
|
||||||
<Button
|
<Button
|
||||||
type="warning"
|
type="warning"
|
||||||
shape="circle"
|
size="middle"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setShowLeave(true);
|
setShowLeave(true);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{t['Leave Workspace']()}
|
{t['Leave']()}
|
||||||
</Button>
|
</Button>
|
||||||
<WorkspaceLeave
|
<WorkspaceLeave
|
||||||
open={showLeave}
|
open={showLeave}
|
||||||
@@ -229,7 +227,8 @@ export const GeneralPanel: React.FC<PanelProps> = ({
|
|||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</StyledRow>
|
</div>
|
||||||
|
</div>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { Input } from '@affine/component';
|
|||||||
export const StyledInput = styled(Input)(() => {
|
export const StyledInput = styled(Input)(() => {
|
||||||
return {
|
return {
|
||||||
border: '1px solid var(--affine-border-color)',
|
border: '1px solid var(--affine-border-color)',
|
||||||
borderRadius: '10px',
|
borderRadius: '8px',
|
||||||
fontSize: 'var(--affine-font-sm)',
|
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 {
|
import {
|
||||||
|
Menu,
|
||||||
|
MenuItem,
|
||||||
Modal,
|
Modal,
|
||||||
ModalCloseButton,
|
ModalCloseButton,
|
||||||
ModalWrapper,
|
ModalWrapper,
|
||||||
@@ -9,14 +11,19 @@ import { useAFFiNEI18N } from '@affine/i18n/hooks';
|
|||||||
import type { AccessTokenMessage } from '@affine/workspace/affine/login';
|
import type { AccessTokenMessage } from '@affine/workspace/affine/login';
|
||||||
import type { AffineWorkspace, LocalWorkspace } from '@affine/workspace/type';
|
import type { AffineWorkspace, LocalWorkspace } from '@affine/workspace/type';
|
||||||
import { WorkspaceFlavour } 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 type { DragEndEvent } from '@dnd-kit/core';
|
||||||
import { useCallback } from 'react';
|
import { useCallback, useRef } from 'react';
|
||||||
|
|
||||||
import type { AllWorkspace } from '../../../shared';
|
import type { AllWorkspace } from '../../../shared';
|
||||||
import { Footer } from '../footer';
|
import { Footer } from '../footer';
|
||||||
import {
|
import {
|
||||||
StyledCreateWorkspaceCard,
|
StyledCreateWorkspaceCard,
|
||||||
|
StyledCreateWorkspaceCardPill,
|
||||||
|
StyledCreateWorkspaceCardPillContainer,
|
||||||
|
StyledCreateWorkspaceCardPillContent,
|
||||||
|
StyledCreateWorkspaceCardPillIcon,
|
||||||
|
StyledCreateWorkspaceCardPillTextSecondary,
|
||||||
StyledHelperContainer,
|
StyledHelperContainer,
|
||||||
StyledModalContent,
|
StyledModalContent,
|
||||||
StyledModalHeader,
|
StyledModalHeader,
|
||||||
@@ -39,7 +46,8 @@ interface WorkspaceModalProps {
|
|||||||
onClickWorkspaceSetting: (workspace: AllWorkspace) => void;
|
onClickWorkspaceSetting: (workspace: AllWorkspace) => void;
|
||||||
onClickLogin: () => void;
|
onClickLogin: () => void;
|
||||||
onClickLogout: () => void;
|
onClickLogout: () => void;
|
||||||
onCreateWorkspace: () => void;
|
onNewWorkspace: () => void;
|
||||||
|
onAddWorkspace: () => void;
|
||||||
onMoveWorkspace: (activeId: string, overId: string) => void;
|
onMoveWorkspace: (activeId: string, overId: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -53,12 +61,13 @@ export const WorkspaceListModal = ({
|
|||||||
onClickLogout,
|
onClickLogout,
|
||||||
onClickWorkspace,
|
onClickWorkspace,
|
||||||
onClickWorkspaceSetting,
|
onClickWorkspaceSetting,
|
||||||
onCreateWorkspace,
|
onNewWorkspace,
|
||||||
|
onAddWorkspace,
|
||||||
currentWorkspaceId,
|
currentWorkspaceId,
|
||||||
onMoveWorkspace,
|
onMoveWorkspace,
|
||||||
}: WorkspaceModalProps) => {
|
}: WorkspaceModalProps) => {
|
||||||
const t = useAFFiNEI18N();
|
const t = useAFFiNEI18N();
|
||||||
|
const anchorEL = useRef<HTMLDivElement>(null);
|
||||||
return (
|
return (
|
||||||
<Modal open={open} onClose={onClose}>
|
<Modal open={open} onClose={onClose}>
|
||||||
<ModalWrapper
|
<ModalWrapper
|
||||||
@@ -115,19 +124,96 @@ export const WorkspaceListModal = ({
|
|||||||
[onMoveWorkspace]
|
[onMoveWorkspace]
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
{!environment.isDesktop && (
|
||||||
<StyledCreateWorkspaceCard
|
<StyledCreateWorkspaceCard
|
||||||
|
onClick={onNewWorkspace}
|
||||||
data-testid="new-workspace"
|
data-testid="new-workspace"
|
||||||
onClick={onCreateWorkspace}
|
|
||||||
>
|
>
|
||||||
<StyleWorkspaceAdd className="add-icon">
|
<StyleWorkspaceAdd className="add-icon">
|
||||||
<PlusIcon />
|
<PlusIcon />
|
||||||
</StyleWorkspaceAdd>
|
</StyleWorkspaceAdd>
|
||||||
|
|
||||||
<StyleWorkspaceInfo>
|
<StyleWorkspaceInfo>
|
||||||
<StyleWorkspaceTitle>{t['New Workspace']()}</StyleWorkspaceTitle>
|
<StyleWorkspaceTitle>
|
||||||
|
{t['New Workspace']()}
|
||||||
|
</StyleWorkspaceTitle>
|
||||||
<p>{t['Create Or Import']()}</p>
|
<p>{t['Create Or Import']()}</p>
|
||||||
</StyleWorkspaceInfo>
|
</StyleWorkspaceInfo>
|
||||||
</StyledCreateWorkspaceCard>
|
</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>
|
</StyledModalContent>
|
||||||
|
|
||||||
<Footer user={user} onLogin={onClickLogin} onLogout={onClickLogout} />
|
<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')(() => {
|
export const StyledModalHeaderLeft = styled('div')(() => {
|
||||||
return { ...displayFlex('flex-start', 'center') };
|
return { ...displayFlex('flex-start', 'center') };
|
||||||
|
|||||||
@@ -77,7 +77,7 @@ export const RootAppSidebar = ({
|
|||||||
const sidebarOpen = useAtomValue(appSidebarOpenAtom);
|
const sidebarOpen = useAtomValue(appSidebarOpenAtom);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (environment.isDesktop && typeof sidebarOpen === 'boolean') {
|
if (environment.isDesktop && typeof sidebarOpen === 'boolean') {
|
||||||
window.apis?.onSidebarVisibilityChange(sidebarOpen);
|
window.apis?.ui.handleSidebarVisibilityChange(sidebarOpen);
|
||||||
}
|
}
|
||||||
}, [sidebarOpen]);
|
}, [sidebarOpen]);
|
||||||
const [ref, setRef] = useState<HTMLElement | null>(null);
|
const [ref, setRef] = useState<HTMLElement | null>(null);
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { DebugLogger } from '@affine/debug';
|
||||||
import { rootCurrentPageIdAtom } from '@affine/workspace/atom';
|
import { rootCurrentPageIdAtom } from '@affine/workspace/atom';
|
||||||
import { useAtom, useAtomValue } from 'jotai';
|
import { useAtom, useAtomValue } from 'jotai';
|
||||||
import type { NextRouter } from 'next/router';
|
import type { NextRouter } from 'next/router';
|
||||||
@@ -5,6 +6,9 @@ import { useEffect, useRef } from 'react';
|
|||||||
|
|
||||||
import { rootCurrentWorkspaceAtom } from '../atoms/root';
|
import { rootCurrentWorkspaceAtom } from '../atoms/root';
|
||||||
export const HALT_PROBLEM_TIMEOUT = 1000;
|
export const HALT_PROBLEM_TIMEOUT = 1000;
|
||||||
|
|
||||||
|
const logger = new DebugLogger('useRouterWithWorkspaceIdDefense');
|
||||||
|
|
||||||
export function useRouterAndWorkspaceWithPageIdDefense(router: NextRouter) {
|
export function useRouterAndWorkspaceWithPageIdDefense(router: NextRouter) {
|
||||||
const currentWorkspace = useAtomValue(rootCurrentWorkspaceAtom);
|
const currentWorkspace = useAtomValue(rootCurrentWorkspaceAtom);
|
||||||
const [currentPageId, setCurrentPageId] = useAtom(rootCurrentPageIdAtom);
|
const [currentPageId, setCurrentPageId] = useAtom(rootCurrentPageIdAtom);
|
||||||
@@ -15,19 +19,19 @@ export function useRouterAndWorkspaceWithPageIdDefense(router: NextRouter) {
|
|||||||
}
|
}
|
||||||
const { workspaceId, pageId } = router.query;
|
const { workspaceId, pageId } = router.query;
|
||||||
if (typeof pageId !== 'string') {
|
if (typeof pageId !== 'string') {
|
||||||
console.warn('pageId is not a string', pageId);
|
logger.warn('pageId is not a string', pageId);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (typeof workspaceId !== 'string') {
|
if (typeof workspaceId !== 'string') {
|
||||||
console.warn('workspaceId is not a string', workspaceId);
|
logger.warn('workspaceId is not a string', workspaceId);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (currentWorkspace?.id !== workspaceId) {
|
if (currentWorkspace?.id !== workspaceId) {
|
||||||
console.warn('workspaceId is not currentWorkspace', workspaceId);
|
logger.warn('workspaceId is not currentWorkspace', workspaceId);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (currentPageId !== pageId && !fallbackModeRef.current) {
|
if (currentPageId !== pageId && !fallbackModeRef.current) {
|
||||||
console.log('set current page id', pageId);
|
logger.info('set pageId', pageId, 'for workspace', workspaceId);
|
||||||
setCurrentPageId(pageId);
|
setCurrentPageId(pageId);
|
||||||
void router.push({
|
void router.push({
|
||||||
pathname: '/workspace/[workspaceId]/[pageId]',
|
pathname: '/workspace/[workspaceId]/[pageId]',
|
||||||
@@ -51,7 +55,7 @@ export function useRouterAndWorkspaceWithPageIdDefense(router: NextRouter) {
|
|||||||
const firstOne =
|
const firstOne =
|
||||||
currentWorkspace.blockSuiteWorkspace.meta.pageMetas.at(0);
|
currentWorkspace.blockSuiteWorkspace.meta.pageMetas.at(0);
|
||||||
if (firstOne) {
|
if (firstOne) {
|
||||||
console.warn(
|
logger.warn(
|
||||||
'cannot find page',
|
'cannot find page',
|
||||||
currentPageId,
|
currentPageId,
|
||||||
'so redirect to',
|
'so redirect to',
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { DebugLogger } from '@affine/debug';
|
||||||
import {
|
import {
|
||||||
rootCurrentPageIdAtom,
|
rootCurrentPageIdAtom,
|
||||||
rootCurrentWorkspaceIdAtom,
|
rootCurrentWorkspaceIdAtom,
|
||||||
@@ -7,6 +8,8 @@ import { useAtom, useAtomValue, useSetAtom } from 'jotai';
|
|||||||
import type { NextRouter } from 'next/router';
|
import type { NextRouter } from 'next/router';
|
||||||
import { useEffect } from 'react';
|
import { useEffect } from 'react';
|
||||||
|
|
||||||
|
const logger = new DebugLogger('useRouterWithWorkspaceIdDefense');
|
||||||
|
|
||||||
export function useRouterWithWorkspaceIdDefense(router: NextRouter) {
|
export function useRouterWithWorkspaceIdDefense(router: NextRouter) {
|
||||||
const metadata = useAtomValue(rootWorkspacesMetadataAtom);
|
const metadata = useAtomValue(rootWorkspacesMetadataAtom);
|
||||||
const [currentWorkspaceId, setCurrentWorkspaceId] = useAtom(
|
const [currentWorkspaceId, setCurrentWorkspaceId] = useAtom(
|
||||||
@@ -30,6 +33,7 @@ export function useRouterWithWorkspaceIdDefense(router: NextRouter) {
|
|||||||
if (!firstOne) {
|
if (!firstOne) {
|
||||||
throw new Error('no workspace');
|
throw new Error('no workspace');
|
||||||
}
|
}
|
||||||
|
logger.debug('redirect to', firstOne.id);
|
||||||
void router.push({
|
void router.push({
|
||||||
pathname: '/workspace/[workspaceId]/all',
|
pathname: '/workspace/[workspaceId]/all',
|
||||||
query: {
|
query: {
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { DebugLogger } from '@affine/debug';
|
||||||
import {
|
import {
|
||||||
rootCurrentWorkspaceIdAtom,
|
rootCurrentWorkspaceIdAtom,
|
||||||
rootWorkspacesMetadataAtom,
|
rootWorkspacesMetadataAtom,
|
||||||
@@ -6,6 +7,8 @@ import { useAtom, useAtomValue } from 'jotai';
|
|||||||
import type { NextRouter } from 'next/router';
|
import type { NextRouter } from 'next/router';
|
||||||
import { useEffect } from 'react';
|
import { useEffect } from 'react';
|
||||||
|
|
||||||
|
const logger = new DebugLogger('useSyncRouterWithCurrentWorkspaceId');
|
||||||
|
|
||||||
export function useSyncRouterWithCurrentWorkspaceId(router: NextRouter) {
|
export function useSyncRouterWithCurrentWorkspaceId(router: NextRouter) {
|
||||||
const [currentWorkspaceId, setCurrentWorkspaceId] = useAtom(
|
const [currentWorkspaceId, setCurrentWorkspaceId] = useAtom(
|
||||||
rootCurrentWorkspaceIdAtom
|
rootCurrentWorkspaceIdAtom
|
||||||
@@ -23,6 +26,7 @@ export function useSyncRouterWithCurrentWorkspaceId(router: NextRouter) {
|
|||||||
if (currentWorkspaceId !== workspaceId) {
|
if (currentWorkspaceId !== workspaceId) {
|
||||||
const target = metadata.find(workspace => workspace.id === workspaceId);
|
const target = metadata.find(workspace => workspace.id === workspaceId);
|
||||||
if (!target) {
|
if (!target) {
|
||||||
|
logger.debug('workspace not exist, redirect to current one');
|
||||||
// workspaceId is invalid, redirect to currentWorkspaceId
|
// workspaceId is invalid, redirect to currentWorkspaceId
|
||||||
void router.push({
|
void router.push({
|
||||||
pathname: router.pathname,
|
pathname: router.pathname,
|
||||||
@@ -41,9 +45,7 @@ export function useSyncRouterWithCurrentWorkspaceId(router: NextRouter) {
|
|||||||
if (targetWorkspace) {
|
if (targetWorkspace) {
|
||||||
console.log('set workspace id', workspaceId);
|
console.log('set workspace id', workspaceId);
|
||||||
setCurrentWorkspaceId(targetWorkspace.id);
|
setCurrentWorkspaceId(targetWorkspace.id);
|
||||||
if (environment.isDesktop) {
|
logger.debug('redirect to', targetWorkspace.id);
|
||||||
window.apis?.onWorkspaceChange(targetWorkspace.id);
|
|
||||||
}
|
|
||||||
void router.push({
|
void router.push({
|
||||||
pathname: router.pathname,
|
pathname: router.pathname,
|
||||||
query: {
|
query: {
|
||||||
@@ -56,6 +58,7 @@ export function useSyncRouterWithCurrentWorkspaceId(router: NextRouter) {
|
|||||||
if (targetWorkspace) {
|
if (targetWorkspace) {
|
||||||
console.log('set workspace id', workspaceId);
|
console.log('set workspace id', workspaceId);
|
||||||
setCurrentWorkspaceId(targetWorkspace.id);
|
setCurrentWorkspaceId(targetWorkspace.id);
|
||||||
|
logger.debug('redirect to', targetWorkspace.id);
|
||||||
void router.push({
|
void router.push({
|
||||||
pathname: router.pathname,
|
pathname: router.pathname,
|
||||||
query: {
|
query: {
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { DebugLogger } from '@affine/debug';
|
import { DebugLogger } from '@affine/debug';
|
||||||
import { rootWorkspacesMetadataAtom } from '@affine/workspace/atom';
|
import { rootWorkspacesMetadataAtom } from '@affine/workspace/atom';
|
||||||
|
import { saveWorkspaceToLocalStorage } from '@affine/workspace/local/crud';
|
||||||
import type { LocalWorkspace } from '@affine/workspace/type';
|
import type { LocalWorkspace } from '@affine/workspace/type';
|
||||||
import { WorkspaceFlavour } from '@affine/workspace/type';
|
import { WorkspaceFlavour } from '@affine/workspace/type';
|
||||||
import { createEmptyBlockSuiteWorkspace } from '@affine/workspace/utils';
|
import { createEmptyBlockSuiteWorkspace } from '@affine/workspace/utils';
|
||||||
@@ -39,6 +40,21 @@ export function useAppHelper() {
|
|||||||
},
|
},
|
||||||
[workspaces]
|
[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(
|
createLocalWorkspace: useCallback(
|
||||||
async (name: string): Promise<string> => {
|
async (name: string): Promise<string> => {
|
||||||
const blockSuiteWorkspace = createEmptyBlockSuiteWorkspace(
|
const blockSuiteWorkspace = createEmptyBlockSuiteWorkspace(
|
||||||
|
|||||||
@@ -97,14 +97,15 @@ const SettingPage: NextPageWithLayout = () => {
|
|||||||
|
|
||||||
const helper = useAppHelper();
|
const helper = useAppHelper();
|
||||||
|
|
||||||
const onDeleteWorkspace = useCallback(() => {
|
const onDeleteWorkspace = useCallback(async () => {
|
||||||
assertExists(currentWorkspace);
|
assertExists(currentWorkspace);
|
||||||
const workspaceId = currentWorkspace.id;
|
const workspaceId = currentWorkspace.id;
|
||||||
if (workspaceIds.length === 1 && workspaceId === workspaceIds[0].id) {
|
if (workspaceIds.length === 1 && workspaceId === workspaceIds[0].id) {
|
||||||
toast(t['You cannot delete the last workspace']());
|
toast(t['You cannot delete the last workspace']());
|
||||||
throw new Error('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]);
|
}, [currentWorkspace, helper, t, workspaceIds]);
|
||||||
const onTransformWorkspace = useOnTransformWorkspace();
|
const onTransformWorkspace = useOnTransformWorkspace();
|
||||||
if (!router.isReady) {
|
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 { useCurrentUser } from '../hooks/current/use-current-user';
|
||||||
import { useCurrentWorkspace } from '../hooks/current/use-current-workspace';
|
import { useCurrentWorkspace } from '../hooks/current/use-current-workspace';
|
||||||
import { useRouterHelper } from '../hooks/use-router-helper';
|
import { useRouterHelper } from '../hooks/use-router-helper';
|
||||||
import { useAppHelper, useWorkspaces } from '../hooks/use-workspaces';
|
import { useWorkspaces } from '../hooks/use-workspaces';
|
||||||
import { WorkspaceSubPath } from '../shared';
|
import { WorkspaceSubPath } from '../shared';
|
||||||
|
|
||||||
const WorkspaceListModal = lazy(() =>
|
const WorkspaceListModal = lazy(() =>
|
||||||
@@ -28,7 +28,7 @@ const WorkspaceListModal = lazy(() =>
|
|||||||
);
|
);
|
||||||
|
|
||||||
const CreateWorkspaceModal = lazy(() =>
|
const CreateWorkspaceModal = lazy(() =>
|
||||||
import('../components/pure/create-workspace-modal').then(module => ({
|
import('../components/affine/create-workspace-modal').then(module => ({
|
||||||
default: module.CreateWorkspaceModal,
|
default: module.CreateWorkspaceModal,
|
||||||
}))
|
}))
|
||||||
);
|
);
|
||||||
@@ -69,7 +69,6 @@ export function Modals() {
|
|||||||
const setWorkspaces = useSetAtom(rootWorkspacesMetadataAtom);
|
const setWorkspaces = useSetAtom(rootWorkspacesMetadataAtom);
|
||||||
const currentWorkspaceId = useAtomValue(currentWorkspaceIdAtom);
|
const currentWorkspaceId = useAtomValue(currentWorkspaceIdAtom);
|
||||||
const [, setCurrentWorkspace] = useCurrentWorkspace();
|
const [, setCurrentWorkspace] = useCurrentWorkspace();
|
||||||
const { createLocalWorkspace } = useAppHelper();
|
|
||||||
const [transitioning, transition] = useTransition();
|
const [transitioning, transition] = useTransition();
|
||||||
const env = getEnvironment();
|
const env = getEnvironment();
|
||||||
const onCloseOnboardingModal = useCallback(() => {
|
const onCloseOnboardingModal = useCallback(() => {
|
||||||
@@ -134,27 +133,28 @@ export function Modals() {
|
|||||||
)}
|
)}
|
||||||
onClickLogin={useAffineLogIn()}
|
onClickLogin={useAffineLogIn()}
|
||||||
onClickLogout={useAffineLogOut()}
|
onClickLogout={useAffineLogOut()}
|
||||||
onCreateWorkspace={useCallback(() => {
|
onNewWorkspace={useCallback(() => {
|
||||||
setOpenCreateWorkspaceModal(true);
|
setOpenCreateWorkspaceModal('new');
|
||||||
|
}, [setOpenCreateWorkspaceModal])}
|
||||||
|
onAddWorkspace={useCallback(async () => {
|
||||||
|
setOpenCreateWorkspaceModal('add');
|
||||||
}, [setOpenCreateWorkspaceModal])}
|
}, [setOpenCreateWorkspaceModal])}
|
||||||
/>
|
/>
|
||||||
</Suspense>
|
</Suspense>
|
||||||
<Suspense>
|
<Suspense>
|
||||||
<CreateWorkspaceModal
|
<CreateWorkspaceModal
|
||||||
open={openCreateWorkspaceModal}
|
mode={openCreateWorkspaceModal}
|
||||||
onClose={useCallback(() => {
|
onClose={useCallback(() => {
|
||||||
setOpenCreateWorkspaceModal(false);
|
setOpenCreateWorkspaceModal(false);
|
||||||
}, [setOpenCreateWorkspaceModal])}
|
}, [setOpenCreateWorkspaceModal])}
|
||||||
onCreate={useCallback(
|
onCreate={useCallback(
|
||||||
async name => {
|
async id => {
|
||||||
const id = await createLocalWorkspace(name);
|
|
||||||
setOpenCreateWorkspaceModal(false);
|
setOpenCreateWorkspaceModal(false);
|
||||||
setOpenWorkspacesModal(false);
|
setOpenWorkspacesModal(false);
|
||||||
setCurrentWorkspace(id);
|
setCurrentWorkspace(id);
|
||||||
return jumpToSubPath(id, WorkspaceSubPath.ALL);
|
return jumpToSubPath(id, WorkspaceSubPath.SETTING);
|
||||||
},
|
},
|
||||||
[
|
[
|
||||||
createLocalWorkspace,
|
|
||||||
jumpToSubPath,
|
jumpToSubPath,
|
||||||
setCurrentWorkspace,
|
setCurrentWorkspace,
|
||||||
setOpenCreateWorkspaceModal,
|
setOpenCreateWorkspaceModal,
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ const DesktopThemeSync = memo(function DesktopThemeSync() {
|
|||||||
const lastThemeRef = useRef(theme);
|
const lastThemeRef = useRef(theme);
|
||||||
if (lastThemeRef.current !== theme) {
|
if (lastThemeRef.current !== theme) {
|
||||||
if (environment.isDesktop && theme) {
|
if (environment.isDesktop && theme) {
|
||||||
window.apis?.onThemeChange(theme);
|
window.apis?.ui.handleThemeChange(theme as 'dark' | 'light' | 'system');
|
||||||
}
|
}
|
||||||
lastThemeRef.current = theme;
|
lastThemeRef.current = theme;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ export const updateAvailableAtom = atomWithObservable<boolean>(() => {
|
|||||||
if (typeof window !== 'undefined') {
|
if (typeof window !== 'undefined') {
|
||||||
const isMacosDesktop = environment.isDesktop && environment.isMacOs;
|
const isMacosDesktop = environment.isDesktop && environment.isMacOs;
|
||||||
if (isMacosDesktop) {
|
if (isMacosDesktop) {
|
||||||
const dispose = window.apis?.onClientUpdateAvailable(() => {
|
const dispose = window.events?.updater.onClientUpdateReady(() => {
|
||||||
subscriber.next(true);
|
subscriber.next(true);
|
||||||
});
|
});
|
||||||
return () => {
|
return () => {
|
||||||
|
|||||||
@@ -124,7 +124,7 @@ export const AppSidebar = forwardRef<HTMLElement, AppSidebarProps>(
|
|||||||
{clientUpdateAvailable && (
|
{clientUpdateAvailable && (
|
||||||
<Button
|
<Button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
window.apis?.onClientUpdateInstall();
|
window.apis?.updater.updateClient();
|
||||||
}}
|
}}
|
||||||
noBorder
|
noBorder
|
||||||
className={updaterButtonStyle}
|
className={updaterButtonStyle}
|
||||||
|
|||||||
@@ -186,6 +186,7 @@ export const StyledButton = styled('button', {
|
|||||||
border: noBorder ? 'none' : '1px solid',
|
border: noBorder ? 'none' : '1px solid',
|
||||||
WebkitAppRegion: 'no-drag',
|
WebkitAppRegion: 'no-drag',
|
||||||
...displayInlineFlex('center', 'center'),
|
...displayInlineFlex('center', 'center'),
|
||||||
|
gap: '10px',
|
||||||
position: 'relative',
|
position: 'relative',
|
||||||
// TODO: disabled color is not decided
|
// TODO: disabled color is not decided
|
||||||
...(disabled
|
...(disabled
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import { StyledMenuWrapper } from './styles';
|
|||||||
export type MenuProps = {
|
export type MenuProps = {
|
||||||
width?: CSSProperties['width'];
|
width?: CSSProperties['width'];
|
||||||
} & PopperProps &
|
} & PopperProps &
|
||||||
Omit<TooltipProps, 'title' | 'content'>;
|
Omit<TooltipProps, 'title' | 'content' | 'placement'>;
|
||||||
export const Menu = (props: MenuProps) => {
|
export const Menu = (props: MenuProps) => {
|
||||||
const { width, content, placement = 'bottom-start', children } = props;
|
const { width, content, placement = 'bottom-start', children } = props;
|
||||||
return content ? (
|
return content ? (
|
||||||
|
|||||||
@@ -16,7 +16,6 @@ function getAtom(w: Workspace, pageId: string): Atom<string> {
|
|||||||
baseAtom.onMount = set => {
|
baseAtom.onMount = set => {
|
||||||
const disposable = w.meta.pageMetasUpdated.on(() => {
|
const disposable = w.meta.pageMetasUpdated.on(() => {
|
||||||
const page = w.getPage(pageId);
|
const page = w.getPage(pageId);
|
||||||
assertExists(page);
|
|
||||||
set(page?.meta.title || 'Untitled');
|
set(page?.meta.title || 'Untitled');
|
||||||
});
|
});
|
||||||
return () => {
|
return () => {
|
||||||
|
|||||||
@@ -81,8 +81,14 @@
|
|||||||
"Jump to": "Jump to",
|
"Jump to": "Jump to",
|
||||||
"404 - Page Not Found": "404 - Page Not Found",
|
"404 - Page Not Found": "404 - Page Not Found",
|
||||||
"New Workspace": "New Workspace",
|
"New Workspace": "New Workspace",
|
||||||
|
"Add Workspace": "Add Workspace",
|
||||||
|
"Add Workspace Hint": "Select where you already have",
|
||||||
"Create": "Create",
|
"Create": "Create",
|
||||||
|
"Create your own workspace": "Create your own workspace",
|
||||||
"Select": "Select",
|
"Select": "Select",
|
||||||
|
"Save": "Save",
|
||||||
|
"Customize": "Customize",
|
||||||
|
"Continue": "Continue",
|
||||||
"Text": "Text (coming soon)",
|
"Text": "Text (coming soon)",
|
||||||
"Shape": "Shape",
|
"Shape": "Shape",
|
||||||
"Sticky": "Sticky (coming soon)",
|
"Sticky": "Sticky (coming soon)",
|
||||||
@@ -94,9 +100,20 @@
|
|||||||
"TrashButtonGroupDescription": "Once deleted, you can't undo this action. Do you confirm?",
|
"TrashButtonGroupDescription": "Once deleted, you can't undo this action. Do you confirm?",
|
||||||
"Delete permanently": "Delete permanently",
|
"Delete permanently": "Delete permanently",
|
||||||
"Link": "Hyperlink (with selected text)",
|
"Link": "Hyperlink (with selected text)",
|
||||||
|
"Name Your Workspace": "Name Your Workspace",
|
||||||
"Workspace description": "A workspace is your virtual space to capture, create and plan as just one person or together as a team.",
|
"Workspace description": "A workspace is your virtual space to capture, create and plan as just one person or together as a team.",
|
||||||
|
"Set database location": "Set database location",
|
||||||
|
"Default Location": "Default Location",
|
||||||
|
"Default db location hint": "By default will be saved to {{location}}",
|
||||||
|
"Workspace database storage description": "Select where you want to create your workspace. The data of workspace is saved locally by default.",
|
||||||
|
"Created Successfully": "Created Successfully",
|
||||||
|
"Added Successfully": "Added Successfully",
|
||||||
"Quick search placeholder": "Quick Search...",
|
"Quick search placeholder": "Quick Search...",
|
||||||
"Quick search placeholder2": "Search in {{workspace}}",
|
"Quick search placeholder2": "Search in {{workspace}}",
|
||||||
|
"Use on current device only": "Use on current device only",
|
||||||
|
"Sync across devices with AFFiNE Cloud": "Sync across devices with AFFiNE Cloud",
|
||||||
|
"Export success": "Export success",
|
||||||
|
"Update workspace name success": "Update workspace name success",
|
||||||
"Settings": "Settings",
|
"Settings": "Settings",
|
||||||
"Recent": "Recent",
|
"Recent": "Recent",
|
||||||
"recommendBrowser": " We recommend the <1>Chrome</1> browser for optimal experience.",
|
"recommendBrowser": " We recommend the <1>Chrome</1> browser for optimal experience.",
|
||||||
@@ -125,6 +142,7 @@
|
|||||||
"login success": "Login success",
|
"login success": "Login success",
|
||||||
"Create Or Import": "Create or Import",
|
"Create Or Import": "Create or Import",
|
||||||
"Delete Workspace": "Delete Workspace",
|
"Delete Workspace": "Delete Workspace",
|
||||||
|
"Delete Workspace Label Hint": "After deleting this Workspace, you will permanently delete all of its content for everyone. No one will be able to recover the content of this Workspace",
|
||||||
"Delete Workspace Description2": "Deleting (<1>{{workspace}}</1>) will delete both local and cloud data, this operation cannot be undone, please proceed with caution.",
|
"Delete Workspace Description2": "Deleting (<1>{{workspace}}</1>) will delete both local and cloud data, this operation cannot be undone, please proceed with caution.",
|
||||||
"Placeholder of delete workspace": "Please type workspace name to confirm",
|
"Placeholder of delete workspace": "Please type workspace name to confirm",
|
||||||
"Leave Workspace": "Leave Workspace",
|
"Leave Workspace": "Leave Workspace",
|
||||||
@@ -232,5 +250,18 @@
|
|||||||
"Page is Loading": "Page is Loading",
|
"Page is Loading": "Page is Loading",
|
||||||
"Loading All Workspaces": "Loading All Workspaces",
|
"Loading All Workspaces": "Loading All Workspaces",
|
||||||
"Loading Page": "Loading Page",
|
"Loading Page": "Loading Page",
|
||||||
"You cannot delete the last workspace": "You cannot delete the last workspace"
|
"You cannot delete the last workspace": "You cannot delete the last workspace",
|
||||||
|
"Open folder": "Open folder",
|
||||||
|
"Open folder hint": "Check the where the storage folder is located.",
|
||||||
|
"Move folder": "Move folder",
|
||||||
|
"Move folder hint": "Select a new storage location.",
|
||||||
|
"Change avatar hint": "Change avatar for all members.",
|
||||||
|
"Change workspace name hint": "Change name for all members.",
|
||||||
|
"Storage Folder": "Storage Folder",
|
||||||
|
"Storage Folder Hint": "Check or change storage location.",
|
||||||
|
"Move folder success": "Move folder success",
|
||||||
|
"DB_FILE_PATH_INVALID": "Database file path invalid",
|
||||||
|
"DB_FILE_ALREADY_LOADED": "Database file already loaded",
|
||||||
|
"UNKNOWN_ERROR": "Unknown error",
|
||||||
|
"DB_FILE_INVALID": "Invalid Database file"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -69,7 +69,7 @@ export const setLoginStorage = (login: LoginResponse) => {
|
|||||||
|
|
||||||
const signInWithElectron = async (firebaseAuth: FirebaseAuth) => {
|
const signInWithElectron = async (firebaseAuth: FirebaseAuth) => {
|
||||||
if (window.apis) {
|
if (window.apis) {
|
||||||
const { url, requestInit } = await window.apis.getGoogleOauthCode();
|
const { url, requestInit } = await window.apis.ui.getGoogleOauthCode();
|
||||||
const { id_token } = await fetch(url, requestInit).then(res => res.json());
|
const { id_token } = await fetch(url, requestInit).then(res => res.json());
|
||||||
const credential = GoogleAuthProvider.credential(id_token);
|
const credential = GoogleAuthProvider.credential(id_token);
|
||||||
const user = await signInWithCredential(firebaseAuth, credential);
|
const user = await signInWithCredential(firebaseAuth, credential);
|
||||||
|
|||||||
@@ -1,24 +1,28 @@
|
|||||||
import type { BlobStorage } from '@blocksuite/store';
|
import type { BlobStorage } from '@blocksuite/store';
|
||||||
|
import { assertExists } from '@blocksuite/store';
|
||||||
|
|
||||||
export const createSQLiteStorage = (workspaceId: string): BlobStorage => {
|
export const createSQLiteStorage = (workspaceId: string): BlobStorage => {
|
||||||
|
const apis = window.apis;
|
||||||
|
assertExists(apis);
|
||||||
return {
|
return {
|
||||||
crud: {
|
crud: {
|
||||||
get: async (key: string) => {
|
get: async (key: string) => {
|
||||||
const buffer = await window.apis.db.getBlob(workspaceId, key);
|
const buffer = await apis.db.getBlob(workspaceId, key);
|
||||||
return buffer ? new Blob([buffer]) : null;
|
return buffer ? new Blob([buffer]) : null;
|
||||||
},
|
},
|
||||||
set: async (key: string, value: Blob) => {
|
set: async (key: string, value: Blob) => {
|
||||||
return window.apis.db.addBlob(
|
await apis.db.addBlob(
|
||||||
workspaceId,
|
workspaceId,
|
||||||
key,
|
key,
|
||||||
new Uint8Array(await value.arrayBuffer())
|
new Uint8Array(await value.arrayBuffer())
|
||||||
);
|
);
|
||||||
|
return key;
|
||||||
},
|
},
|
||||||
delete: async (key: string) => {
|
delete: async (key: string) => {
|
||||||
return window.apis.db.deleteBlob(workspaceId, key);
|
return apis.db.deleteBlob(workspaceId, key);
|
||||||
},
|
},
|
||||||
list: async () => {
|
list: async () => {
|
||||||
return window.apis.db.getPersistedBlobs(workspaceId);
|
return apis.db.getPersistedBlobs(workspaceId);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -102,7 +102,7 @@ export const CRUD: WorkspaceCRUD<WorkspaceFlavour.LOCAL> = {
|
|||||||
|
|
||||||
// workspaces in desktop
|
// workspaces in desktop
|
||||||
if (window.apis && environment.isDesktop) {
|
if (window.apis && environment.isDesktop) {
|
||||||
const desktopIds = await window.apis.workspace.list();
|
const desktopIds = (await window.apis.workspace.list()).map(([id]) => id);
|
||||||
// the ids maybe a subset of the local storage
|
// the ids maybe a subset of the local storage
|
||||||
const moreWorkspaces = desktopIds.filter(
|
const moreWorkspaces = desktopIds.filter(
|
||||||
id => !allWorkspaceIDs.includes(id)
|
id => !allWorkspaceIDs.includes(id)
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ const mockedAddBlob = vi.fn();
|
|||||||
vi.stubGlobal('window', {
|
vi.stubGlobal('window', {
|
||||||
apis: {
|
apis: {
|
||||||
db: {
|
db: {
|
||||||
getDoc: async () => {
|
getDocAsUpdates: async () => {
|
||||||
return Y.encodeStateAsUpdate(offlineYdoc);
|
return Y.encodeStateAsUpdate(offlineYdoc);
|
||||||
},
|
},
|
||||||
applyDocUpdate: async (id: string, update: Uint8Array) => {
|
applyDocUpdate: async (id: string, update: Uint8Array) => {
|
||||||
@@ -31,15 +31,24 @@ vi.stubGlobal('window', {
|
|||||||
// todo: may need to hack the way to get hash keys of blobs
|
// todo: may need to hack the way to get hash keys of blobs
|
||||||
return [];
|
return [];
|
||||||
},
|
},
|
||||||
onDBUpdate: (fn: (id: string) => void) => {
|
addBlob: mockedAddBlob,
|
||||||
|
} satisfies Partial<NonNullable<typeof window.apis>['db']>,
|
||||||
|
},
|
||||||
|
events: {
|
||||||
|
db: {
|
||||||
|
onDbFileUpdate: (fn: (id: string) => void) => {
|
||||||
triggerDBUpdate = fn;
|
triggerDBUpdate = fn;
|
||||||
return () => {
|
return () => {
|
||||||
triggerDBUpdate = null;
|
triggerDBUpdate = null;
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
addBlob: mockedAddBlob,
|
|
||||||
} satisfies Partial<typeof window.apis.db>,
|
// not used in this test
|
||||||
|
onDbFileMissing: () => {
|
||||||
|
return () => {};
|
||||||
},
|
},
|
||||||
|
},
|
||||||
|
} satisfies Partial<NonNullable<typeof window.events>>,
|
||||||
});
|
});
|
||||||
|
|
||||||
vi.stubGlobal('environment', {
|
vi.stubGlobal('environment', {
|
||||||
|
|||||||
@@ -162,18 +162,21 @@ const createSQLiteProvider = (
|
|||||||
blockSuiteWorkspace: BlockSuiteWorkspace
|
blockSuiteWorkspace: BlockSuiteWorkspace
|
||||||
): SQLiteProvider => {
|
): SQLiteProvider => {
|
||||||
const sqliteOrigin = Symbol('sqlite-provider-origin');
|
const sqliteOrigin = Symbol('sqlite-provider-origin');
|
||||||
|
const apis = window.apis!;
|
||||||
|
const events = window.events!;
|
||||||
// make sure it is being used in Electron with APIs
|
// make sure it is being used in Electron with APIs
|
||||||
assertExists(environment.isDesktop && window.apis);
|
assertExists(apis);
|
||||||
|
assertExists(events);
|
||||||
|
|
||||||
function handleUpdate(update: Uint8Array, origin: unknown) {
|
function handleUpdate(update: Uint8Array, origin: unknown) {
|
||||||
if (origin === sqliteOrigin) {
|
if (origin === sqliteOrigin) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
window.apis.db.applyDocUpdate(blockSuiteWorkspace.id, update);
|
apis.db.applyDocUpdate(blockSuiteWorkspace.id, update);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function syncBlobIntoSQLite(bs: BlobManager) {
|
async function syncBlobIntoSQLite(bs: BlobManager) {
|
||||||
const persistedKeys = await window.apis.db.getPersistedBlobs(
|
const persistedKeys = await apis.db.getPersistedBlobs(
|
||||||
blockSuiteWorkspace.id
|
blockSuiteWorkspace.id
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -188,7 +191,7 @@ const createSQLiteProvider = (
|
|||||||
logger.warn('blob not found for', k);
|
logger.warn('blob not found for', k);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
return window.apis.db.addBlob(
|
return window.apis?.db.addBlob(
|
||||||
blockSuiteWorkspace.id,
|
blockSuiteWorkspace.id,
|
||||||
k,
|
k,
|
||||||
new Uint8Array(await blob.arrayBuffer())
|
new Uint8Array(await blob.arrayBuffer())
|
||||||
@@ -199,7 +202,7 @@ const createSQLiteProvider = (
|
|||||||
|
|
||||||
async function syncUpdates() {
|
async function syncUpdates() {
|
||||||
logger.info('syncing updates from sqlite', blockSuiteWorkspace.id);
|
logger.info('syncing updates from sqlite', blockSuiteWorkspace.id);
|
||||||
const updates = await window.apis.db.getDoc(blockSuiteWorkspace.id);
|
const updates = await apis.db.getDocAsUpdates(blockSuiteWorkspace.id);
|
||||||
|
|
||||||
if (updates) {
|
if (updates) {
|
||||||
Y.applyUpdate(blockSuiteWorkspace.doc, updates, sqliteOrigin);
|
Y.applyUpdate(blockSuiteWorkspace.doc, updates, sqliteOrigin);
|
||||||
@@ -208,7 +211,7 @@ const createSQLiteProvider = (
|
|||||||
const mergeUpdates = Y.encodeStateAsUpdate(blockSuiteWorkspace.doc);
|
const mergeUpdates = Y.encodeStateAsUpdate(blockSuiteWorkspace.doc);
|
||||||
|
|
||||||
// also apply updates to sqlite
|
// also apply updates to sqlite
|
||||||
window.apis.db.applyDocUpdate(blockSuiteWorkspace.id, mergeUpdates);
|
apis.db.applyDocUpdate(blockSuiteWorkspace.id, mergeUpdates);
|
||||||
|
|
||||||
const bs = blockSuiteWorkspace.blobs;
|
const bs = blockSuiteWorkspace.blobs;
|
||||||
|
|
||||||
@@ -240,7 +243,7 @@ const createSQLiteProvider = (
|
|||||||
blockSuiteWorkspace.doc.on('update', handleUpdate);
|
blockSuiteWorkspace.doc.on('update', handleUpdate);
|
||||||
|
|
||||||
let timer = 0;
|
let timer = 0;
|
||||||
unsubscribe = window.apis.db.onDBUpdate(workspaceId => {
|
unsubscribe = events.db.onDbFileUpdate(workspaceId => {
|
||||||
if (workspaceId === blockSuiteWorkspace.id) {
|
if (workspaceId === blockSuiteWorkspace.id) {
|
||||||
// throttle
|
// throttle
|
||||||
logger.debug('on db update', workspaceId);
|
logger.debug('on db update', workspaceId);
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ test('should create a page with a local first avatar', async ({ page }) => {
|
|||||||
await page
|
await page
|
||||||
.getByTestId('create-workspace-input')
|
.getByTestId('create-workspace-input')
|
||||||
.type('Test Workspace 1', { delay: 50 });
|
.type('Test Workspace 1', { delay: 50 });
|
||||||
await page.getByTestId('create-workspace-button').click();
|
await page.getByTestId('create-workspace-create-button').click();
|
||||||
await page.getByTestId('workspace-name').click();
|
await page.getByTestId('workspace-name').click();
|
||||||
await page.getByTestId('workspace-card').nth(1).click();
|
await page.getByTestId('workspace-card').nth(1).click();
|
||||||
await page.getByTestId('slider-bar-workspace-setting-button').click();
|
await page.getByTestId('slider-bar-workspace-setting-button').click();
|
||||||
|
|||||||
@@ -17,7 +17,8 @@ test('Create new workspace, then delete it', async ({ page }) => {
|
|||||||
await page
|
await page
|
||||||
.getByTestId('create-workspace-input')
|
.getByTestId('create-workspace-input')
|
||||||
.type('Test Workspace', { delay: 50 });
|
.type('Test Workspace', { delay: 50 });
|
||||||
await page.getByTestId('create-workspace-button').click();
|
await page.getByTestId('create-workspace-create-button').click();
|
||||||
|
|
||||||
await page.waitForTimeout(1000);
|
await page.waitForTimeout(1000);
|
||||||
await page.waitForSelector('[data-testid="workspace-name"]');
|
await page.waitForSelector('[data-testid="workspace-name"]');
|
||||||
expect(await page.getByTestId('workspace-name').textContent()).toBe(
|
expect(await page.getByTestId('workspace-name').textContent()).toBe(
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ export default defineConfig({
|
|||||||
'packages/**/*.spec.tsx',
|
'packages/**/*.spec.tsx',
|
||||||
'apps/web/**/*.spec.ts',
|
'apps/web/**/*.spec.ts',
|
||||||
'apps/web/**/*.spec.tsx',
|
'apps/web/**/*.spec.tsx',
|
||||||
|
'apps/electron/layers/**/*.spec.ts',
|
||||||
'tests/unit/**/*.spec.ts',
|
'tests/unit/**/*.spec.ts',
|
||||||
'tests/unit/**/*.spec.tsx',
|
'tests/unit/**/*.spec.tsx',
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -142,6 +142,7 @@ __metadata:
|
|||||||
"@types/better-sqlite3": ^7.6.4
|
"@types/better-sqlite3": ^7.6.4
|
||||||
"@types/fs-extra": ^11.0.1
|
"@types/fs-extra": ^11.0.1
|
||||||
better-sqlite3: ^8.3.0
|
better-sqlite3: ^8.3.0
|
||||||
|
chokidar: ^3.5.3
|
||||||
cross-env: 7.0.3
|
cross-env: 7.0.3
|
||||||
electron: 24.2.0
|
electron: 24.2.0
|
||||||
electron-log: ^5.0.0-beta.23
|
electron-log: ^5.0.0-beta.23
|
||||||
@@ -150,7 +151,9 @@ __metadata:
|
|||||||
electron-window-state: ^5.0.3
|
electron-window-state: ^5.0.3
|
||||||
esbuild: ^0.17.18
|
esbuild: ^0.17.18
|
||||||
fs-extra: ^11.1.1
|
fs-extra: ^11.1.1
|
||||||
|
nanoid: ^4.0.2
|
||||||
playwright: ^1.33.0
|
playwright: ^1.33.0
|
||||||
|
rxjs: ^7.8.1
|
||||||
ts-node: ^10.9.1
|
ts-node: ^10.9.1
|
||||||
undici: ^5.22.0
|
undici: ^5.22.0
|
||||||
yjs: ^13.6.1
|
yjs: ^13.6.1
|
||||||
|
|||||||
Reference in New Issue
Block a user