diff --git a/apps/desktop/src/main/github-sync.test.ts b/apps/desktop/src/main/github-sync.test.ts new file mode 100644 index 00000000..fb7c8507 --- /dev/null +++ b/apps/desktop/src/main/github-sync.test.ts @@ -0,0 +1,203 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' + +vi.mock('node:child_process', () => ({ + execFile: vi.fn() +})) +vi.mock('./secret-store', () => ({ + getRemoteWorkspaceSecret: vi.fn().mockResolvedValue(null), + setRemoteWorkspaceSecret: vi.fn().mockResolvedValue(true) +})) +vi.mock('./vault', () => ({ + loadConfig: vi.fn().mockResolvedValue({}), + updateConfig: vi.fn().mockImplementation(async (fn: (cfg: any) => any) => fn({})) +})) + +import { execFile } from 'node:child_process' + +const mockExecOnce = (stdout: string, stderr = ''): void => { + vi.mocked(execFile).mockImplementationOnce((_cmd, _args, _opts, cb) => { + if (cb) cb(null, stdout, stderr) + return {} as ReturnType + }) +} + +const mockExecErrorOnce = (msg: string): void => { + vi.mocked(execFile).mockImplementationOnce((_cmd, _args, _opts, cb) => { + if (cb) cb(new Error(msg), '', msg) + return {} as ReturnType + }) +} + +describe('github-sync', () => { + afterEach(() => { + vi.unstubAllGlobals() + }) + + describe('listRepos', () => { + it('fetches repos from GitHub API, sorted by creation date', async () => { + const mockResponse = [ + { full_name: 'user/repo1', created_at: '2024-01-01T00:00:00Z' }, + { full_name: 'user/repo2', created_at: '2025-06-01T00:00:00Z' } + ] + vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ + ok: true, + json: () => Promise.resolve(mockResponse) + })) + + const { listRepos } = await import('./github-sync') + const repos = await listRepos('ghp_token') + expect(repos).toEqual(['user/repo2', 'user/repo1']) + }) + + it('throws on API error', async () => { + vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ + ok: false, + status: 401, + text: () => Promise.resolve('Bad credentials') + })) + + const { listRepos } = await import('./github-sync') + await expect(listRepos('bad_token')).rejects.toThrow('GitHub API error') + }) + }) + + describe('sync', () => { + it('returns error when git is missing', async () => { + mockExecErrorOnce('command not found: git') + const { sync } = await import('./github-sync') + const result = await sync('/some/vault', 'ghp_token', 'user/repo') + expect(result.ok).toBe(false) + expect((result as any).error).toContain('Git is not installed') + }) + + it('returns merge conflict error on pull conflict', async () => { + mockExecOnce('git version 2.40.0') + mockExecOnce('.git') + mockExecOnce('') + mockExecOnce('https://ghp_token:x-oauth-basic@github.com/user/repo.git') + mockExecOnce('refs/heads/main') + mockExecErrorOnce( + 'Auto-merging notes/note.md\nCONFLICT (content): Merge conflict in notes/note.md\nAutomatic merge failed' + ) + + const { sync } = await import('./github-sync') + const result = await sync('/some/vault', 'ghp_token', 'user/repo') + expect(result.ok).toBe(false) + expect((result as any).error).toContain('Merge conflict') + }) + + it('returns no-local-changes when git status --porcelain is empty', async () => { + mockExecOnce('git version 2.40.0') + mockExecOnce('.git') + mockExecOnce('') + mockExecOnce('https://ghp_token:x-oauth-basic@github.com/user/repo.git') + mockExecOnce('refs/heads/main') + mockExecOnce('Already up to date.') + mockExecOnce('') + + const { sync } = await import('./github-sync') + const result = await sync('/some/vault', 'ghp_token', 'user/repo') + expect(result.ok).toBe(true) + expect((result as any).message).toContain('No local changes') + }) + + it('succeeds when there are local changes', async () => { + mockExecOnce('git version 2.40.0') + mockExecOnce('.git') + mockExecOnce('') + mockExecOnce('https://ghp_token:x-oauth-basic@github.com/user/repo.git') + mockExecOnce('refs/heads/main') + mockExecOnce('Already up to date.') + mockExecOnce('M note.md') + mockExecOnce('') + mockExecOnce('') + mockExecOnce('Everything up-to-date') + + const { sync } = await import('./github-sync') + const result = await sync('/some/vault', 'ghp_token', 'user/repo') + expect(result.ok).toBe(true) + expect((result as any).message).toBe('Notes synced successfully.') + }) + + it('returns error when ls-remote fails with Could not read from remote', async () => { + mockExecOnce('git version 2.40.0') + mockExecOnce('.git') + mockExecOnce('') + mockExecOnce('https://ghp_token:x-oauth-basic@github.com/user/repo.git') + mockExecErrorOnce('Could not read from remote repository') + + const { sync } = await import('./github-sync') + const result = await sync('/some/vault', 'ghp_token', 'user/repo') + expect(result.ok).toBe(false) + expect((result as any).error).toBe('Cannot reach GitHub. Check your repo name and internet connection.') + }) + + it('throws on non-conflict pull failure', async () => { + mockExecOnce('git version 2.40.0') + mockExecOnce('.git') + mockExecOnce('') + mockExecOnce('https://ghp_token:x-oauth-basic@github.com/user/repo.git') + mockExecOnce('refs/heads/main') + mockExecErrorOnce('Something went wrong during pull') + + const { sync } = await import('./github-sync') + const result = await sync('/some/vault', 'ghp_token', 'user/repo') + expect(result.ok).toBe(false) + expect((result as any).error).toBe('Something went wrong during pull') + }) + + it('returns error on non-fast-forward push rejection', async () => { + mockExecOnce('git version 2.40.0') + mockExecOnce('.git') + mockExecOnce('') + mockExecOnce('https://ghp_token:x-oauth-basic@github.com/user/repo.git') + mockExecOnce('refs/heads/main') + mockExecOnce('Already up to date.') + mockExecOnce('M note.md') + mockExecOnce('') + mockExecOnce('') + mockExecErrorOnce('rejected: non-fast-forward') + + const { sync } = await import('./github-sync') + const result = await sync('/some/vault', 'ghp_token', 'user/repo') + expect(result.ok).toBe(false) + expect((result as any).error).toContain('Pull remote changes first') + }) + + it('returns error on push connection failure', async () => { + mockExecOnce('git version 2.40.0') + mockExecOnce('.git') + mockExecOnce('') + mockExecOnce('https://ghp_token:x-oauth-basic@github.com/user/repo.git') + mockExecOnce('refs/heads/main') + mockExecOnce('Already up to date.') + mockExecOnce('M note.md') + mockExecOnce('') + mockExecOnce('') + mockExecErrorOnce('Connection refused') + + const { sync } = await import('./github-sync') + const result = await sync('/some/vault', 'ghp_token', 'user/repo') + expect(result.ok).toBe(false) + expect((result as any).error).toContain('Cannot reach GitHub') + }) + }) + + describe('handleSync', () => { + it('returns error when PAT is missing', async () => { + const { handleSync } = await import('./github-sync') + const result = await handleSync('/some/vault') + expect(result.ok).toBe(false) + expect((result as any).error).toBe('GitHub PAT not configured.') + }) + + it('returns error when repo is missing', async () => { + const secretStore = await import('./secret-store') + vi.mocked(secretStore.getRemoteWorkspaceSecret).mockResolvedValueOnce('ghp_token') + const { handleSync } = await import('./github-sync') + const result = await handleSync('/some/vault') + expect(result.ok).toBe(false) + expect((result as any).error).toBe('GitHub repo not configured.') + }) + }) +}) diff --git a/apps/desktop/src/main/github-sync.ts b/apps/desktop/src/main/github-sync.ts new file mode 100644 index 00000000..a2c6f49c --- /dev/null +++ b/apps/desktop/src/main/github-sync.ts @@ -0,0 +1,214 @@ +import { execFile } from 'node:child_process' +import { getRemoteWorkspaceSecret, setRemoteWorkspaceSecret } from './secret-store' +import { loadConfig, updateConfig } from './vault' +import type { PersistedConfig } from './vault' +import type { GithubConfig, GithubSyncResult } from '@shared/ipc' + +const GIT_BRANCH = 'main' +const GITHUB_PAT_KEY = 'github-pat' + +function readGithubRepo(cfg: PersistedConfig): string | null { + return cfg.githubRepo?.trim() || null +} + +function runGit(args: string[], cwd: string): Promise { + return new Promise((resolve, reject) => { + const child = execFile('git', args, { cwd, timeout: 60_000 }, (err, stdout, stderr) => { + if (err) { + const message = stderr.trim() || err.message + reject(new Error(message)) + return + } + resolve(stdout.trim()) + }) + child.on('error', (err) => reject(new Error(`Failed to spawn git: ${err.message}`))) + }) +} + +async function checkGit(): Promise { + try { + await runGit(['--version'], '/') + return null + } catch { + return 'Git is not installed. Install git and restart ZenNotes.' + } +} + +async function ensureGitRepo(vaultRoot: string): Promise { + try { + await runGit(['rev-parse', '--git-dir'], vaultRoot) + } catch { + await runGit(['init'], vaultRoot) + } +} + +async function setupRemote(vaultRoot: string, repo: string, pat: string): Promise { + const authUrl = `https://${pat}:x-oauth-basic@github.com/${repo}.git` + try { + const currentRemote = await runGit(['remote', 'get-url', 'origin'], vaultRoot) + if (currentRemote !== authUrl) { + await runGit(['remote', 'set-url', 'origin', authUrl], vaultRoot) + } + } catch { + await runGit(['remote', 'add', 'origin', authUrl], vaultRoot) + } +} + +function parseMergeConflictStderr(stderr: string): string | null { + const lines = stderr.split(/\r?\n/) + const conflictLines = lines.filter( + (l) => l.includes('CONFLICT') || l.includes('merge conflict') || l.includes('Merge conflict') + ) + if (conflictLines.length === 0) return null + return conflictLines.join('; ') +} + +async function prepareRepo(vaultRoot: string, pat: string, repo: string): Promise { + const gitMissing = await checkGit() + if (gitMissing) return gitMissing + await ensureGitRepo(vaultRoot) + await runGit(['checkout', '-B', GIT_BRANCH], vaultRoot).catch(() => {}) + await setupRemote(vaultRoot, repo, pat) + return null +} + +async function pullRemote(vaultRoot: string): Promise { + try { + const remoteOutput = await runGit(['ls-remote', '--heads', 'origin', GIT_BRANCH], vaultRoot) + if (remoteOutput) { + try { + await runGit(['pull', 'origin', GIT_BRANCH, '--rebase', '--autostash'], vaultRoot) + } catch (pullErr) { + const stderr = pullErr instanceof Error ? pullErr.message : String(pullErr) + const conflict = parseMergeConflictStderr(stderr) + if (conflict) { + await runGit(['merge', '--abort'], vaultRoot).catch(() => { }) + return `Merge conflict detected: ${conflict}. Resolve manually via git, then retry sync.` + } + throw pullErr + } + } + return null + } catch (err) { + if (err instanceof Error && err.message.includes('Could not read from remote repository')) { + return 'Cannot reach GitHub. Check your repo name and internet connection.' + } + throw err + } +} + +async function hasLocalChanges(vaultRoot: string): Promise { + const status = await runGit(['status', '--porcelain'], vaultRoot) + return status.length > 0 +} + +async function stageAndCommit(vaultRoot: string): Promise { + await runGit(['add', '-A'], vaultRoot) + const timestamp = new Date().toISOString() + await runGit(['commit', '-m', `zennotes-sync ${timestamp}`], vaultRoot) +} + +async function pushLocal(vaultRoot: string): Promise { + try { + await runGit(['push', 'origin', GIT_BRANCH], vaultRoot) + return null + } catch (err) { + const message = err instanceof Error ? err.message : String(err) + if (message.includes('rejected') && message.includes('non-fast-forward')) { + return 'Remote has changes you don\'t have locally. Pull remote changes first, then retry sync.' + } + if (message.includes('Connection refused') || message.includes('Could not read from remote')) { + return 'Cannot reach GitHub. Check your internet connection.' + } + return `Push failed: ${message}` + } +} + +export async function listRepos(pat: string): Promise { + const response = await fetch('https://api.github.com/user/repos?per_page=100&sort=created', { + headers: { + Authorization: `Bearer ${pat}`, + Accept: 'application/vnd.github+json', + 'User-Agent': 'ZenNotes', + 'Cache-Control': 'no-cache' + } + }) + if (!response.ok) { + const body = await response.text().catch(() => '') + throw new Error(`GitHub API error (${response.status}): ${body}`) + } + const repos = (await response.json()) as Array<{ full_name: string; created_at: string }> + repos.sort((a, b) => b.created_at.localeCompare(a.created_at)) + return repos.map((r) => r.full_name) +} + +export async function sync(vaultRoot: string, pat: string, repo: string): Promise { + try { + const prepErr = await prepareRepo(vaultRoot, pat, repo) + if (prepErr) return { ok: false, error: prepErr } + + const pullErr = await pullRemote(vaultRoot) + if (pullErr) return { ok: false, error: pullErr } + + if (!(await hasLocalChanges(vaultRoot))) { + return { ok: true, message: 'No local changes to sync.' } + } + + await stageAndCommit(vaultRoot) + const pushErr = await pushLocal(vaultRoot) + if (pushErr) return { ok: false, error: pushErr } + + return { ok: true, message: 'Notes synced successfully.' } + } catch (err) { + const message = err instanceof Error ? err.message : String(err) + return { ok: false, error: message } + } +} + +export async function handleGetConfig(): Promise { + const pat = await getRemoteWorkspaceSecret(GITHUB_PAT_KEY) + const repo = readGithubRepo(await loadConfig()) + return { pat, repo } +} + +export async function handleSetConfig(config: GithubConfig): Promise { + await setRemoteWorkspaceSecret(GITHUB_PAT_KEY, config.pat) + await updateConfig((cfg) => ({ + ...cfg, + githubRepo: config.repo ?? null + })) +} + +export async function handleListRepos(): Promise { + const pat = await getRemoteWorkspaceSecret(GITHUB_PAT_KEY) + if (!pat) return [] + try { + return await listRepos(pat) + } catch (err) { + const msg = err instanceof Error ? err.message : String(err) + if (msg.includes('401')) { + throw new Error('Invalid token. Check your PAT permissions.') + } + if (msg.includes('403')) { + throw new Error('Token does not have permission to list repositories.') + } + if ( + msg.includes('fetch failed') || + msg.includes('ENOTFOUND') || + msg.includes('ECONNREFUSED') || + msg.includes('ENETUNREACH') || + msg.includes('ETIMEDOUT') + ) { + throw new Error('Cannot reach GitHub. Check your internet connection.') + } + throw new Error('GitHub returned an error. Try again.') + } +} + +export async function handleSync(vaultRoot: string): Promise { + const pat = await getRemoteWorkspaceSecret(GITHUB_PAT_KEY) + if (!pat) return { ok: false, error: 'GitHub PAT not configured.' } + const repo = readGithubRepo(await loadConfig()) + if (!repo) return { ok: false, error: 'GitHub repo not configured.' } + return await sync(vaultRoot, pat, repo) +} diff --git a/apps/desktop/src/main/index.ts b/apps/desktop/src/main/index.ts index f9d36958..35398764 100644 --- a/apps/desktop/src/main/index.ts +++ b/apps/desktop/src/main/index.ts @@ -34,7 +34,6 @@ import type { RemoteWorkspaceProfileInput, ServerCapabilities, VaultSettings, - VaultChangeEvent, VaultInfo, VaultTextSearchBackendPreference, VaultTextSearchToolPaths @@ -158,6 +157,13 @@ import { markdownPathsFromArgv, resolveMarkdownOpenTarget } from './file-open' +import { + handleGetConfig as ghHandleGetConfig, + handleSetConfig as ghHandleSetConfig, + handleListRepos as ghHandleListRepos, + handleSync as ghHandleSync +} from './github-sync' +import type { GithubConfig, GithubSyncResult } from '@shared/ipc' const __dirname = path.dirname(fileURLToPath(import.meta.url)) const LOCAL_ASSET_SCHEME = 'zen-asset' @@ -247,7 +253,7 @@ function windowIconPath(): string { function openAllowedExternalUrl(url: string): void { if (/^(https?:|mailto:)/i.test(url)) { - shell.openExternal(url).catch(() => {}) + shell.openExternal(url).catch(() => { }) } } @@ -259,10 +265,10 @@ function registerAppDeepLinkProtocol(): void { const didRegister = defaultApp && process.argv[1] ? app.setAsDefaultProtocolClient( - ZENNOTES_DEEP_LINK_SCHEME, - process.execPath, - [path.resolve(process.argv[1])] - ) + ZENNOTES_DEEP_LINK_SCHEME, + process.execPath, + [path.resolve(process.argv[1])] + ) : app.setAsDefaultProtocolClient(ZENNOTES_DEEP_LINK_SCHEME) if (!didRegister) { @@ -869,17 +875,17 @@ async function createWindow(options: CreateWindowOptions = {}): Promise { ...current, remoteWorkspace: nextRemoteWorkspace ? { - baseUrl: normalizeRemoteBaseUrl(nextRemoteWorkspace.baseUrl) - } + baseUrl: normalizeRemoteBaseUrl(nextRemoteWorkspace.baseUrl) + } : null, remoteWorkspaceProfiles: nextProfiles.map((profile) => ({ id: profile.id, @@ -1457,12 +1463,12 @@ async function exportNotePdf( trafficLightPosition: { x: 12, y: 12 }, ...(mac ? { - backgroundColor: '#ffffff' - } + backgroundColor: '#ffffff' + } : { - backgroundColor: '#ffffff', - icon: windowIconPath() - }), + backgroundColor: '#ffffff', + icon: windowIconPath() + }), webPreferences: { preload: path.join(__dirname, '../preload/index.js'), sandbox: false, @@ -1605,7 +1611,7 @@ async function deleteRemoteWorkspaceProfile(id: string): Promise { !!deletedProfile && !!cfg.remoteWorkspace && normalizeRemoteBaseUrl(cfg.remoteWorkspace.baseUrl) === - normalizeRemoteBaseUrl(deletedProfile.baseUrl) && + normalizeRemoteBaseUrl(deletedProfile.baseUrl) && !nextProfiles.some( (entry) => normalizeRemoteBaseUrl(entry.baseUrl) === normalizeRemoteBaseUrl(deletedProfile.baseUrl) @@ -2648,6 +2654,13 @@ function registerIpc(): void { handle(IPC.CLI_UNINSTALL, async () => await uninstallCli()) handle(IPC.RAYCAST_GET_STATUS, async () => await getRaycastExtensionStatus()) handle(IPC.RAYCAST_INSTALL, async () => await installRaycastExtension()) + handle(IPC.GITHUB_GET_CONFIG, async () => await ghHandleGetConfig()) + handle(IPC.GITHUB_SET_CONFIG, async (_ev, config: GithubConfig) => await ghHandleSetConfig(config)) + handle(IPC.GITHUB_LIST_REPOS, async () => await ghHandleListRepos()) + handle(IPC.GITHUB_SYNC, async () => { + const v = requireVault() + return await ghHandleSync(v.root) + }) } /** @@ -2680,12 +2693,12 @@ function openFloatingNoteWindow(relPath: string): void { trafficLightPosition: { x: 12, y: 12 }, ...(mac ? { - backgroundColor: MAC_WINDOW_BACKGROUND_COLOR - } + backgroundColor: MAC_WINDOW_BACKGROUND_COLOR + } : { - backgroundColor: '#faf7f0', - icon: windowIconPath() - }), + backgroundColor: '#faf7f0', + icon: windowIconPath() + }), webPreferences: { preload: path.join(__dirname, '../preload/index.js'), // Keep the renderer isolated and node-free, but the current preload diff --git a/apps/desktop/src/main/vault.ts b/apps/desktop/src/main/vault.ts index 1fd2dc91..679d7133 100644 --- a/apps/desktop/src/main/vault.ts +++ b/apps/desktop/src/main/vault.ts @@ -218,6 +218,8 @@ export interface PersistedConfig { /** When true, the quick-capture window stays pinned on top of all windows * and does not auto-hide when it loses focus. */ quickCapturePinned: boolean + /** GitHub repo in "owner/repo" format for sync. */ + githubRepo?: string | null } export const DEFAULT_QUICK_CAPTURE_HOTKEY = 'CommandOrControl+Shift+Space' @@ -232,7 +234,8 @@ const DEFAULT_CONFIG: PersistedConfig = { windowState: null, zoomFactor: 1, quickCaptureHotkey: DEFAULT_QUICK_CAPTURE_HOTKEY, - quickCapturePinned: false + quickCapturePinned: false, + githubRepo: null } let configWriteQueue = Promise.resolve() @@ -368,7 +371,11 @@ function normalizePersistedConfig(value: unknown): PersistedConfig { windowState: normalizeWindowState(candidate.windowState), zoomFactor, quickCaptureHotkey, - quickCapturePinned + quickCapturePinned, + githubRepo: + typeof candidate.githubRepo === 'string' && candidate.githubRepo.trim() + ? candidate.githubRepo.trim() + : null } } diff --git a/apps/desktop/src/preload/index.ts b/apps/desktop/src/preload/index.ts index b27a0975..eb3f3acf 100644 --- a/apps/desktop/src/preload/index.ts +++ b/apps/desktop/src/preload/index.ts @@ -19,6 +19,8 @@ import type { DirectoryBrowseResult, ExternalFileContent, FolderEntry, + GithubConfig, + GithubSyncResult, ImportedAsset, LocalVaultEntry, MoveExternalFileResult, @@ -478,6 +480,10 @@ const api: ZenBridge = { ipcRenderer.invoke(IPC.RAYCAST_GET_STATUS), raycastInstall: (): Promise => ipcRenderer.invoke(IPC.RAYCAST_INSTALL), + getGithubConfig: (): Promise => ipcRenderer.invoke(IPC.GITHUB_GET_CONFIG), + setGithubConfig: (config: GithubConfig): Promise => ipcRenderer.invoke(IPC.GITHUB_SET_CONFIG, config), + listGithubRepos: (): Promise => ipcRenderer.invoke(IPC.GITHUB_LIST_REPOS), + syncWithGithub: (): Promise => ipcRenderer.invoke(IPC.GITHUB_SYNC), clipboardWriteText: (text: string): void => clipboard.writeText(text), clipboardReadText: (): string => clipboard.readText() } diff --git a/apps/web/src/bridge/http-bridge.ts b/apps/web/src/bridge/http-bridge.ts index 4c376062..330f8ff4 100644 --- a/apps/web/src/bridge/http-bridge.ts +++ b/apps/web/src/bridge/http-bridge.ts @@ -33,6 +33,8 @@ import type { DirectoryBrowseResult, ExternalFileContent, FolderEntry, + GithubConfig, + GithubSyncResult, ImportedAsset, LocalVaultEntry, MoveExternalFileResult, @@ -1054,6 +1056,20 @@ async function raycastInstall(): Promise { return notImplemented('raycastInstall') } +async function getGithubConfig(): Promise { + return { pat: null, repo: null } +} + +async function setGithubConfig(): Promise {} + +async function listGithubRepos(): Promise { + return [] +} + +async function syncWithGithub(): Promise { + return { ok: false, error: 'GitHub sync is only available in the desktop build.' } +} + // -------------------------------------------------------------------- // Clipboard (web build uses navigator.clipboard) // -------------------------------------------------------------------- @@ -1203,6 +1219,10 @@ export const httpBridge: ZenBridge = { cliUninstall, raycastGetStatus, raycastInstall, + getGithubConfig, + setGithubConfig, + listGithubRepos, + syncWithGithub, clipboardWriteText, clipboardReadText } diff --git a/packages/app-core/src/App.tsx b/packages/app-core/src/App.tsx index 8a916bb4..f8f397c5 100644 --- a/packages/app-core/src/App.tsx +++ b/packages/app-core/src/App.tsx @@ -166,6 +166,16 @@ const SettingsModal = lazy(async () => { return { default: module.SettingsModal } }) +const GithubSyncModal = lazy(async () => { + const module = await import('./components/GithubSyncModal') + return { default: module.GithubSyncModal } +}) + +const GithubConfigModal = lazy(async () => { + const module = await import('./components/GithubConfigModal') + return { default: module.GithubConfigModal } +}) + const EmptyVault = lazy(async () => { const module = await import('./components/EmptyVault') return { default: module.EmptyVault } @@ -260,6 +270,8 @@ function App(): JSX.Element { const pinnedRefVisible = useStore((s) => s.pinnedRefVisible) const unifiedSidebar = useStore((s) => s.unifiedSidebar) const settingsOpen = useStore((s) => s.settingsOpen) + const githubSyncOpen = useStore((s) => s.githubSyncOpen) + const githubConfigOpen = useStore((s) => s.githubConfigOpen) const setSettingsOpen = useStore((s) => s.setSettingsOpen) const themeId = useStore((s) => s.themeId) const themeFamily = useStore((s) => s.themeFamily) @@ -747,6 +759,16 @@ function App(): JSX.Element { )} + {githubSyncOpen && ( + + useStore.getState().setGithubSyncOpen(false)} /> + + )} + {githubConfigOpen && ( + + useStore.getState().setGithubConfigOpen(false)} /> + + )} diff --git a/packages/app-core/src/components/GithubConfigModal.tsx b/packages/app-core/src/components/GithubConfigModal.tsx new file mode 100644 index 00000000..b7b93cd8 --- /dev/null +++ b/packages/app-core/src/components/GithubConfigModal.tsx @@ -0,0 +1,75 @@ +import { useCallback, useState } from 'react' +import { Modal } from './ui/Modal' +import { Button } from './ui/Button' +import { Spinner } from './ui/Spinner' +import { GithubConfigPanel } from './GithubConfigPanel' +import { useGithubConfig } from '../lib/use-github-config' + +export function GithubConfigModal({ onClose }: { onClose: () => void }): JSX.Element { + const { + pat, setPat, + repo, setRepo, + repos, + fetchingRepos, fetchRepos, + searchQuery, setSearchQuery, + repoError, + loading + } = useGithubConfig() + + const [confirming, setConfirming] = useState(false) + + const confirmChanges = useCallback(async () => { + if (!pat.trim() || !repo.trim()) return + setConfirming(true) + try { + await window.zen.setGithubConfig({ pat: pat.trim(), repo: repo.trim() }) + onClose() + } catch (err) { + setConfirming(false) + /* repoError is managed by the hook; the fetch-repos path already handles it */ + } + }, [pat, repo, onClose]) + + return ( + + +
+ Configure GitHub Sync +
+
+ Update your GitHub personal access token and repository for syncing notes. +
+
+ + + {loading ? ( + + ) : ( + + )} + + + + + + +
+ ) +} diff --git a/packages/app-core/src/components/GithubConfigPanel.tsx b/packages/app-core/src/components/GithubConfigPanel.tsx new file mode 100644 index 00000000..f2960d3a --- /dev/null +++ b/packages/app-core/src/components/GithubConfigPanel.tsx @@ -0,0 +1,100 @@ +import { Button } from './ui/Button' + +interface GithubConfigPanelProps { + pat: string + onPatChange: (pat: string) => void + repo: string + onRepoChange: (repo: string) => void + repos: string[] + fetchingRepos: boolean + onFetchRepos: () => void + searchQuery: string + onSearchQueryChange: (query: string) => void + repoError: string +} + +export function GithubConfigPanel({ + pat, + onPatChange, + repo, + onRepoChange, + repos, + fetchingRepos, + onFetchRepos, + searchQuery, + onSearchQueryChange, + repoError +}: GithubConfigPanelProps): JSX.Element { + return ( +
+ {repo && ( +
+ Selected repo: {repo} +
+ )} +
+ + onPatChange(e.target.value)} + placeholder="ghp_..." + className="w-full rounded-lg border border-paper-300 bg-paper-50 px-3 py-2 text-sm text-ink-900 outline-none placeholder:text-ink-400 focus:ring-2 focus:ring-accent/50" + /> +
+
+ + + {repos.length > 0 && ( +
+ onSearchQueryChange(e.target.value)} + placeholder="Search repos..." + className="w-full rounded-lg border border-paper-300 bg-paper-50 px-3 py-2 text-sm text-ink-900 outline-none placeholder:text-ink-400 focus:ring-2 focus:ring-accent/50" + /> +
+ {repos + .filter((r) => + r.toLowerCase().includes(searchQuery.toLowerCase()) + ) + .map((r) => ( +
onRepoChange(r)} + > + {r} +
+ ))} + {repos.filter((r) => + r.toLowerCase().includes(searchQuery.toLowerCase()) + ).length === 0 && ( +
+ No repos match "{searchQuery}" +
+ )} +
+
+ )} + {repoError &&

{repoError}

} +
+
+ ) +} diff --git a/packages/app-core/src/components/GithubSyncModal.tsx b/packages/app-core/src/components/GithubSyncModal.tsx new file mode 100644 index 00000000..2279c554 --- /dev/null +++ b/packages/app-core/src/components/GithubSyncModal.tsx @@ -0,0 +1,166 @@ +import { useCallback, useEffect, useRef, useState } from 'react' +import { Modal } from './ui/Modal' +import { Button } from './ui/Button' +import { Spinner } from './ui/Spinner' +import { GithubConfigPanel } from './GithubConfigPanel' +import { useGithubConfig, cleanError } from '../lib/use-github-config' + +type View = 'config' | 'syncing' | 'result' + +interface SyncStatus { + message: string + error?: string +} + +const AUTO_CLOSE_DELAY_MS = 2_000 + +export function GithubSyncModal({ onClose }: { onClose: () => void }): JSX.Element { + const { + pat, setPat, + repo, setRepo, + repos, + fetchingRepos, fetchRepos, + searchQuery, setSearchQuery, + repoError, + loading, + } = useGithubConfig() + + const [view, setView] = useState('config') + const [status, setStatus] = useState(null) + const [saving, setSaving] = useState(false) + const autoCloseTimer = useRef | null>(null) + + function scheduleAutoClose(): void { + autoCloseTimer.current = setTimeout(() => { + onClose() + }, AUTO_CLOSE_DELAY_MS) + } + + useEffect(() => { + return () => { + if (autoCloseTimer.current) clearTimeout(autoCloseTimer.current) + } + }, []) + + useEffect(() => { + if (pat && repo && !loading) { + setView('syncing') + syncWithGithub() + } + }, [loading]) + + async function syncWithGithub(): Promise { + setStatus(null) + try { + const result = await window.zen.syncWithGithub() + setStatus({ message: result.ok ? result.message : '', error: result.ok ? undefined : result.error }) + setView('result') + if (result.ok) scheduleAutoClose() + } catch (err) { + setStatus({ message: '', error: cleanError(err) }) + setView('result') + } + } + + const saveConfig = useCallback(async () => { + if (!pat.trim() || !repo.trim()) return + setSaving(true) + try { + await window.zen.setGithubConfig({ pat: pat.trim(), repo: repo.trim() }) + setView('syncing') + await syncWithGithub() + } catch (err) { + setStatus({ message: '', error: cleanError(err) }) + setView('result') + } finally { + setSaving(false) + } + }, [pat, repo]) + + return ( + + +
+ Sync with GitHub +
+
+ {view === 'config' + ? 'Configure your GitHub personal access token and repository to sync your notes.' + : view === 'syncing' + ? 'Syncing notes with GitHub...' + : status?.error + ? status.error + : status?.message ?? ''} +
+
+ + + {view === 'config' && ( + + )} + + {view === 'syncing' && } + + {view === 'result' && ( +
+ {(status?.error || status?.message) && ( +
+ {status.error || status.message} +
+ )} +
+ Repo: {repo} +
+
+ )} +
+ + {view !== 'syncing' && ( + + {view === 'config' && ( + <> + + + + )} + + {view === 'result' && ( + <> + {status?.error && ( + + )} + + + )} + + + )} +
+ ) +} diff --git a/packages/app-core/src/components/ui/Spinner.tsx b/packages/app-core/src/components/ui/Spinner.tsx new file mode 100644 index 00000000..1b2924f4 --- /dev/null +++ b/packages/app-core/src/components/ui/Spinner.tsx @@ -0,0 +1,7 @@ +export function Spinner({ className = '' }: { className?: string }): JSX.Element { + return ( +
+
+
+ ) +} diff --git a/packages/app-core/src/components/ui/index.ts b/packages/app-core/src/components/ui/index.ts index 3c854484..74cd4ad3 100644 --- a/packages/app-core/src/components/ui/index.ts +++ b/packages/app-core/src/components/ui/index.ts @@ -2,3 +2,4 @@ export { Button, IconButton } from './Button' export type { ButtonProps, ButtonVariant, ButtonSize, IconButtonProps, IconButtonSize } from './Button' export { Modal } from './Modal' export type { ModalProps, ModalSize, ModalLayer } from './Modal' +export { Spinner } from './Spinner' diff --git a/packages/app-core/src/lib/commands.ts b/packages/app-core/src/lib/commands.ts index 63e703ac..0c74d8e4 100644 --- a/packages/app-core/src/lib/commands.ts +++ b/packages/app-core/src/lib/commands.ts @@ -1296,6 +1296,22 @@ export function buildCommands(options?: { includeUnavailable?: boolean }): Comma keywords: 'preferences', run: () => getState().setSettingsOpen(true) }, + { + id: 'sync.github', + title: 'Sync with GitHub…', + category: 'App', + keywords: 'github sync backup push pull git remote', + when: () => window.zen.getAppInfo().runtime === 'desktop', + run: () => getState().setGithubSyncOpen(true) + }, + { + id: 'sync.github.configure', + title: 'Configure GitHub Sync…', + category: 'App', + keywords: 'github configure setup pat token repo repository', + when: () => window.zen.getAppInfo().runtime === 'desktop', + run: () => getState().setGithubConfigOpen(true) + }, { id: 'app.vault.pick', title: diff --git a/packages/app-core/src/lib/use-github-config.ts b/packages/app-core/src/lib/use-github-config.ts new file mode 100644 index 00000000..fc05a908 --- /dev/null +++ b/packages/app-core/src/lib/use-github-config.ts @@ -0,0 +1,82 @@ +import { useCallback, useEffect, useState } from 'react' + +export function cleanError(err: unknown): string { + const msg = err instanceof Error ? err.message : String(err) + const idx = msg.indexOf("': ") + if (idx !== -1) return msg.slice(idx + 3) + return msg +} + +export interface UseGithubConfigReturn { + pat: string + setPat: (pat: string) => void + repo: string + setRepo: (repo: string) => void + repos: string[] + fetchingRepos: boolean + fetchRepos: () => Promise + searchQuery: string + setSearchQuery: (query: string) => void + repoError: string + loading: boolean + clearConfig: () => Promise +} + +export function useGithubConfig(): UseGithubConfigReturn { + const [pat, setPat] = useState('') + const [repo, setRepo] = useState('') + const [repos, setRepos] = useState([]) + const [fetchingRepos, setFetchingRepos] = useState(false) + const [searchQuery, setSearchQuery] = useState('') + const [repoError, setRepoError] = useState('') + const [loading, setLoading] = useState(true) + + useEffect(() => { + window.zen.getGithubConfig().then((cfg) => { + if (cfg.pat) setPat(cfg.pat) + if (cfg.repo) setRepo(cfg.repo) + setLoading(false) + }).catch(() => setLoading(false)) + }, []) + + const fetchRepos = useCallback(async () => { + if (!pat.trim()) return + setFetchingRepos(true) + setRepoError('') + setSearchQuery('') + try { + await window.zen.setGithubConfig({ pat: pat.trim(), repo: null }) + const list = await window.zen.listGithubRepos() + if (list.length === 0) { + setRepoError('No repos found. Check your PAT permissions.') + } + setRepos(list) + } catch (err) { + setRepoError(cleanError(err)) + setRepos([]) + } finally { + setFetchingRepos(false) + } + }, [pat]) + + const clearConfig = useCallback(async () => { + setPat('') + setRepo('') + setRepos([]) + setSearchQuery('') + setRepoError('') + await window.zen.setGithubConfig({ pat: null, repo: null }) + }, []) + + return { + pat, setPat, + repo, setRepo, + repos, + fetchingRepos, + fetchRepos, + searchQuery, setSearchQuery, + repoError, + loading, + clearConfig + } +} diff --git a/packages/app-core/src/store.ts b/packages/app-core/src/store.ts index 052fb93e..5aca4c7f 100644 --- a/packages/app-core/src/store.ts +++ b/packages/app-core/src/store.ts @@ -1476,6 +1476,8 @@ interface Store { tabsEnabled: boolean wrapTabs: boolean settingsOpen: boolean + githubSyncOpen: boolean + githubConfigOpen: boolean themeId: string themeFamily: ThemeFamily themeMode: ThemeMode @@ -1755,6 +1757,8 @@ interface Store { setTabsEnabled: (on: boolean) => void setWrapTabs: (on: boolean) => void setSettingsOpen: (open: boolean) => void + setGithubSyncOpen: (open: boolean) => void + setGithubConfigOpen: (open: boolean) => void setTheme: (next: { id: string; family: ThemeFamily; mode: ThemeMode }) => void setEditorFontSize: (px: number) => void setEditorLineHeight: (mult: number) => void @@ -2698,6 +2702,8 @@ export const useStore = create((set, get) => { tabsEnabled: loadPrefs().tabsEnabled, wrapTabs: loadPrefs().wrapTabs, settingsOpen: false, + githubSyncOpen: false, + githubConfigOpen: false, themeId: loadPrefs().themeId, themeFamily: loadPrefs().themeFamily, themeMode: loadPrefs().themeMode, @@ -4107,6 +4113,8 @@ export const useStore = create((set, get) => { savePrefs(collectPrefs(get())) }, setSettingsOpen: (open) => set({ settingsOpen: open }), + setGithubSyncOpen: (open) => set({ githubSyncOpen: open }), + setGithubConfigOpen: (open) => set({ githubConfigOpen: open }), setTheme: ({ id, family, mode }) => { set({ themeId: id, themeFamily: family, themeMode: mode }) savePrefs(collectPrefs(get())) diff --git a/packages/bridge-contract/src/bridge.ts b/packages/bridge-contract/src/bridge.ts index 169d7476..e87e8a65 100644 --- a/packages/bridge-contract/src/bridge.ts +++ b/packages/bridge-contract/src/bridge.ts @@ -5,6 +5,8 @@ import type { DeletedAsset, ExternalFileContent, FolderEntry, + GithubConfig, + GithubSyncResult, ImportedAsset, LocalVaultEntry, MoveExternalFileResult, @@ -222,6 +224,10 @@ export interface ZenBridge { cliUninstall(): Promise raycastGetStatus(): Promise raycastInstall(): Promise + getGithubConfig(): Promise + setGithubConfig(config: GithubConfig): Promise + listGithubRepos(): Promise + syncWithGithub(): Promise clipboardWriteText(text: string): void clipboardReadText(): string } diff --git a/packages/bridge-contract/src/ipc.ts b/packages/bridge-contract/src/ipc.ts index 6899638f..33dc1c6c 100644 --- a/packages/bridge-contract/src/ipc.ts +++ b/packages/bridge-contract/src/ipc.ts @@ -112,7 +112,11 @@ export const IPC = { CLI_INSTALL: 'cli:install', CLI_UNINSTALL: 'cli:uninstall', RAYCAST_GET_STATUS: 'raycast:get-status', - RAYCAST_INSTALL: 'raycast:install' + RAYCAST_INSTALL: 'raycast:install', + GITHUB_GET_CONFIG: 'github:get-config', + GITHUB_SET_CONFIG: 'github:set-config', + GITHUB_LIST_REPOS: 'github:list-repos', + GITHUB_SYNC: 'github:sync' } as const export interface TikzRenderResponse { @@ -536,3 +540,12 @@ export interface VaultChangeEvent { folder: NoteFolder scope?: VaultChangeScope } + +export interface GithubConfig { + pat: string | null + repo: string | null +} + +export type GithubSyncResult = + | { ok: true; message: string } + | { ok: false; error: string }