diff --git a/src/global-state.ts b/src/global-state.ts index eaa00148..2bb5eb61 100644 --- a/src/global-state.ts +++ b/src/global-state.ts @@ -19,14 +19,8 @@ import { fs, fsWipe } from "./utils/fs" import { REPO_DIR, getRemoteOriginUrl, - gitAdd, - gitClone, - gitCommit, - gitPull, - gitPush, - gitRemove, - isRepoSynced, } from "./utils/git" +import { worker } from "./utils/git-worker-client" import { parseNote } from "./utils/parse-note" import { removeTemplateFrontmatter } from "./utils/remove-template-frontmatter" import { getSampleMarkdownFiles } from "./utils/sample-markdown-files" @@ -341,7 +335,7 @@ function createGlobalStateMachine() { cloneRepo: async (context, event) => { if (!context.githubUser) throw new Error("Not signed in") - await gitClone(event.githubRepo, context.githubUser) + await worker.git.clone({ repo: event.githubRepo, user: context.githubUser }) return { markdownFiles: await getMarkdownFilesFromFs(REPO_DIR), @@ -350,7 +344,7 @@ function createGlobalStateMachine() { pull: async (context) => { if (!context.githubUser) throw new Error("Not signed in") - await gitPull(context.githubUser) + await worker.git.pull({ user: context.githubUser }) return { markdownFiles: await getMarkdownFilesFromFs(REPO_DIR), @@ -359,10 +353,11 @@ function createGlobalStateMachine() { push: async (context) => { if (!context.githubUser) throw new Error("Not signed in") - await gitPush(context.githubUser) + await worker.git.push({ user: context.githubUser }) }, checkStatus: async () => { - return { isSynced: await isRepoSynced() } + const result = await worker.git.status({}) + return { isSynced: result.isSynced } }, writeFiles: async (context, event) => { if (!context.githubUser) throw new Error("Not signed in") @@ -395,10 +390,10 @@ function createGlobalStateMachine() { }) // Stage files - await gitAdd(Object.keys(markdownFiles)) + await worker.git.add({ filePaths: Object.keys(markdownFiles) }) // Commit files - await gitCommit(commitMessage) + await worker.git.commit({ message: commitMessage }) }, deleteFile: async (context, event) => { if (!context.githubUser) throw new Error("Not signed in") @@ -409,10 +404,10 @@ function createGlobalStateMachine() { await fs.promises.unlink(`${REPO_DIR}/${filepath}`) // Stage deletion - await gitRemove(filepath) + await worker.git.remove({ filePath: filepath }) // Commit deletion - await gitCommit(`Delete ${filepath}`) + await worker.git.commit({ message: `Delete ${filepath}` }) }, }, actions: { diff --git a/src/hooks/attach-file.ts b/src/hooks/attach-file.ts index c93b398b..7ac932ed 100644 --- a/src/hooks/attach-file.ts +++ b/src/hooks/attach-file.ts @@ -4,7 +4,8 @@ import React from "react" import { fileCache } from "../components/file-preview" import { githubRepoAtom, githubUserAtom } from "../global-state" import { fs, writeFile } from "../utils/fs" -import { REPO_DIR, gitAdd, gitCommit } from "../utils/git" +import { REPO_DIR } from "../utils/git" +import { worker } from "../utils/git-worker-client" export const UPLOADS_DIR = "/uploads" @@ -46,10 +47,10 @@ export function useAttachFile() { const relativePath = path.replace(/^\//, "") // Stage file - await gitAdd([relativePath]) + await worker.git.add({ filePaths: [relativePath] }) // Commit file - await gitCommit(`Update ${relativePath}`) + await worker.git.commit({ message: `Update ${relativePath}` }) }) .catch((error) => { console.error(error) diff --git a/src/utils/fs.ts b/src/utils/fs.ts index 8fca6856..d68237cf 100644 --- a/src/utils/fs.ts +++ b/src/utils/fs.ts @@ -17,7 +17,8 @@ export const fs = new LightningFS(DB_NAME) /** Delete file system database */ export function fsWipe() { - window.indexedDB.deleteDatabase(DB_NAME) + const indexedDB = globalThis.indexedDB + indexedDB.deleteDatabase(DB_NAME) } /** diff --git a/src/utils/git-worker-client.ts b/src/utils/git-worker-client.ts new file mode 100644 index 00000000..7655befd --- /dev/null +++ b/src/utils/git-worker-client.ts @@ -0,0 +1,140 @@ +import type { + WorkerRequest, + WorkerResponse, + GitCloneRequest, + GitPullRequest, + GitPushRequest, + GitCommitRequest, + GitAddRequest, + GitRemoveRequest, + GitStatusRequest +} from './git-worker-types' + +class WorkerClient { + private worker: Worker | null = null + private requestId = 0 + private pendingRequests = new Map void + reject: (error: Error) => void + }>() + + constructor() { + this.initWorker() + } + + private initWorker() { + try { + this.worker = new Worker( + new URL('./git-worker.ts', import.meta.url), + { type: 'module' } + ) + + this.worker.onmessage = (event: MessageEvent) => { + const { data } = event + const pending = this.pendingRequests.get(data.id) + + if (!pending) { + console.warn('Received response for unknown request ID:', data.id) + return + } + + this.pendingRequests.delete(data.id) + + if (data.type === 'error') { + const errorPayload = data.payload as { error?: string } + const errorMessage = errorPayload?.error || 'Unknown error' + pending.reject(new Error(errorMessage)) + } else if (data.type === 'success') { + pending.resolve(data.payload) + } + } + + this.worker.onerror = (error) => { + console.error('Worker error:', error) + for (const [id, pending] of this.pendingRequests) { + pending.reject(new Error('Worker error')) + this.pendingRequests.delete(id) + } + } + } catch (error) { + console.error('Failed to initialize worker:', error) + } + } + + private sendRequest(category: 'git', type: string, payload: WorkerRequest['payload']): Promise { + return new Promise((resolve, reject) => { + if (!this.worker) { + reject(new Error('Worker not available')) + return + } + + const id = (++this.requestId).toString() + const request: WorkerRequest = { id, category, type, payload } + + this.pendingRequests.set(id, { + resolve: resolve as (value: unknown) => void, + reject + }) + this.worker.postMessage(request) + + setTimeout(() => { + if (this.pendingRequests.has(id)) { + this.pendingRequests.delete(id) + reject(new Error('Request timeout')) + } + }, 30000) + }) + } + + // Git operations + git = { + clone: (options: GitCloneRequest): Promise => { + return this.sendRequest('git', 'clone', options) + }, + + pull: (options: GitPullRequest): Promise => { + return this.sendRequest('git', 'pull', options) + }, + + push: (options: GitPushRequest): Promise => { + return this.sendRequest('git', 'push', options) + }, + + commit: (options: GitCommitRequest): Promise => { + return this.sendRequest('git', 'commit', options) + }, + + add: (options: GitAddRequest): Promise => { + return this.sendRequest('git', 'add', options) + }, + + remove: (options: GitRemoveRequest): Promise => { + return this.sendRequest('git', 'remove', options) + }, + + status: (options: GitStatusRequest = {}): Promise<{ isSynced: boolean }> => { + return this.sendRequest<{ isSynced: boolean }>('git', 'status', options) + } + } + + dispose() { + if (this.worker) { + for (const [id, pending] of this.pendingRequests) { + pending.reject(new Error('Worker disposed')) + this.pendingRequests.delete(id) + } + + this.worker.terminate() + this.worker = null + } + } +} + +// Singleton +export const worker = new WorkerClient() + +if (typeof window !== 'undefined') { + window.addEventListener('beforeunload', () => { + worker.dispose() + }) +} diff --git a/src/utils/git-worker-types.ts b/src/utils/git-worker-types.ts new file mode 100644 index 00000000..a705b400 --- /dev/null +++ b/src/utils/git-worker-types.ts @@ -0,0 +1,60 @@ +// Types for communication between main thread and worker + +export interface WorkerRequest { + id: string + category: 'git' + type: string + payload: GitCloneRequest | GitPullRequest | GitPushRequest | GitCommitRequest | GitAddRequest | GitRemoveRequest | GitStatusRequest +} + +export interface WorkerResponse { + id: string + type: 'success' | 'error' + payload: unknown +} + +export interface GitCloneRequest { + repo: { + owner: string + name: string + } + user: { + login: string + name: string + email: string + token: string + } +} + +export interface GitPullRequest { + user: { + login: string + name: string + email: string + token: string + } +} + +export interface GitPushRequest { + user: { + login: string + name: string + email: string + token: string + } +} + +export interface GitCommitRequest { + message: string +} + +export interface GitAddRequest { + filePaths: string[] +} + +export interface GitRemoveRequest { + filePath: string +} + +export interface GitStatusRequest { +} diff --git a/src/utils/git-worker.ts b/src/utils/git-worker.ts new file mode 100644 index 00000000..cad5648f --- /dev/null +++ b/src/utils/git-worker.ts @@ -0,0 +1,111 @@ +// Worker - Runs all heavy operations in a separate thread +// This prevents blocking the main UI thread + +import { + gitClone, + gitPull, + gitPush, + gitCommit, + gitAdd, + gitRemove, + isRepoSynced, +} from "./git" +import type { + WorkerRequest, + WorkerResponse, + GitCloneRequest, + GitPullRequest, + GitPushRequest, + GitCommitRequest, + GitAddRequest, + GitRemoveRequest +} from "./git-worker-types" + +// Helper function to send success response +function sendSuccess(id: string, result?: unknown) { + const response: WorkerResponse = { + id, + type: 'success', + payload: result || {} + } + + self.postMessage(response) +} + +// Helper function to send error response +function sendError(id: string, error: Error | string) { + const response: WorkerResponse = { + id, + type: 'error', + payload: { error: error instanceof Error ? error.message : error } + } + + self.postMessage(response) +} + +// Message handler +self.onmessage = async (event: MessageEvent) => { + const request = event.data + + try { + if (request.category === 'git') { + switch (request.type) { + case 'clone': { + const payload = request.payload as GitCloneRequest + await gitClone(payload.repo, payload.user) + sendSuccess(request.id) + break + } + + case 'pull': { + const payload = request.payload as GitPullRequest + await gitPull(payload.user) + sendSuccess(request.id) + break + } + + case 'push': { + const payload = request.payload as GitPushRequest + await gitPush(payload.user) + sendSuccess(request.id) + break + } + + case 'commit': { + const payload = request.payload as GitCommitRequest + await gitCommit(payload.message) + sendSuccess(request.id) + break + } + + case 'add': { + const payload = request.payload as GitAddRequest + await gitAdd(payload.filePaths) + sendSuccess(request.id) + break + } + + case 'remove': { + const payload = request.payload as GitRemoveRequest + await gitRemove(payload.filePath) + sendSuccess(request.id) + break + } + + case 'status': { + const isSynced = await isRepoSynced() + sendSuccess(request.id, { isSynced }) + break + } + + default: + sendError(request.id, `Unknown git operation: ${request.type}`) + } + } else { + sendError(request.id, `Unknown category: ${request.category}`) + } + } catch (error) { + console.error('Worker error:', error) + sendError(request.id, error instanceof Error ? error : String(error)) + } +}