/
raiden
/
obsidian-git-encrypt
Обзор
Документация
Войти
/
raiden
/
obsidian-git-encrypt
Код
Запросы
0
Задачи
Вики
Пакеты
0
Релизы
0
CI/CD
Аналитика
Безопасность
master
src/main.ts
427 строк
14 KB
Robert Kuzhin
feat: add manual encrypted source control
09 авг 2026, 20:57
09 авг 2026, 20:57
3c8fa38
Код
Авторство
О чём код?
import path from "node:path"; import { FileSystemAdapter, Notice, Platform, Plugin, } from "obsidian"; import { ObsidianVaultWatcher } from "./obsidian/index.js"; import { FilesystemSessionBackend, SessionError, SessionManager, type SessionState, } from "./session/index.js"; import { loadSettings, type EncryptedGitSettings } from "./settings/index.js"; import type { DiffTarget, SourceControlStatus } from "./sourceControl/index.js"; import { EncryptedGitSettingsTab, type SettingsTabController, } from "./settings/settingsTab.js"; import { ENCRYPTED_GIT_SOURCE_CONTROL_VIEW, ENCRYPTED_GIT_DIFF_VIEW, EncryptedGitDiffView, EncryptedGitSourceControlView, EncryptedGitStatusBar, openChangePasswordModal, openConflictModal, openCreateVaultModal, openSourceDiffView, openTrackedFilesModal, openUnlockModal, type SourceControlViewController, } from "./ui/index.js"; export default class EncryptedGitPlugin extends Plugin { private controller: PluginController | undefined; public override async onload(): Promise<void> { if (!Platform.isDesktopApp || !(this.app.vault.adapter instanceof FileSystemAdapter)) { new Notice("Encrypted Git is available only in Obsidian Desktop with a filesystem vault."); return; } this.controller = new PluginController(this, this.app.vault.adapter.getBasePath()); await this.controller.load(); } public override onunload(): void { void this.controller?.unload(); this.controller = undefined; } } class PluginController implements SettingsTabController, SourceControlViewController { private settings!: EncryptedGitSettings; private session!: SessionManager; private statusBar!: EncryptedGitStatusBar; private unlockPromptOpen = false; private readonly repositoryListeners = new Set<() => void>(); public constructor( private readonly plugin: EncryptedGitPlugin, private readonly vaultRoot: string, ) {} public async load(): Promise<void> { this.settings = loadSettings(await this.plugin.loadData(), this.vaultRoot); const backend = new FilesystemSessionBackend({ vaultRoot: this.vaultRoot, getSettings: () => this.settings, createWatcher: (gate) => new ObsidianVaultWatcher(this.plugin.app, gate), onBackgroundError: () => { new Notice("Encrypted Git could not update the local encrypted mirror."); }, resolveConflicts: async (conflicts) => { const strategies = await openConflictModal(this.plugin.app, conflicts); if (strategies === undefined) throw new SessionError("Conflict resolution was cancelled"); return strategies; }, onRepositoryChange: () => this.notifyRepositoryChange(), }); this.session = new SessionManager(backend); this.statusBar = new EncryptedGitStatusBar(this.plugin.addStatusBarItem()); this.plugin.register(this.session.subscribe((state) => { this.statusBar.update(state); this.notifyRepositoryChange(); })); this.plugin.registerView( ENCRYPTED_GIT_SOURCE_CONTROL_VIEW, (leaf) => new EncryptedGitSourceControlView(leaf, this), ); this.plugin.registerView( ENCRYPTED_GIT_DIFF_VIEW, (leaf) => new EncryptedGitDiffView(leaf), ); this.plugin.addRibbonIcon("git-pull-request", "Encrypted Git Source Control", () => { void this.openSourceControl(); }); this.registerCommands(); this.plugin.addSettingTab(new EncryptedGitSettingsTab(this.plugin.app, this.plugin, this)); } public async unload(): Promise<void> { if (this.session?.getState().hasKey === true) { await this.session.lock({ force: true }).catch(() => undefined); } } public getSettings(): EncryptedGitSettings { return this.settings; } public getSessionState(): SessionState { return this.session.getState(); } public isUnlocked(): boolean { return this.session.getState().hasKey; } public getDefaultCommitMessage(): string { return this.settings.commitMessage; } public getBranchName(): string { return this.settings.branch; } public subscribeRepository(listener: () => void): () => void { this.repositoryListeners.add(listener); return () => this.repositoryListeners.delete(listener); } public async saveSettings(settings: EncryptedGitSettings): Promise<void> { assertLexicallySeparated(this.vaultRoot, settings.mirrorPath); assertLexicallySeparated(settings.statePath, settings.mirrorPath); assertStatePathDoesNotEnterSyncScope(this.vaultRoot, settings.statePath); this.settings = settings; await this.plugin.saveData(settings); } public async lock(): Promise<void> { await this.runSessionOperation(() => this.session.lock(), "Encrypted vault locked."); } public async showTrackedFiles(): Promise<void> { try { openTrackedFilesModal(this.plugin.app, await this.session.listTrackedFiles()); } catch { new Notice("Unable to read the encrypted manifest. Unlock the vault and retry."); } } public async getSourceControlStatus(): Promise<SourceControlStatus | undefined> { try { return await this.session.getSourceControlStatus(); } catch { return undefined; } } public async stage(fileId: string): Promise<void> { await this.runQuietOperation(() => this.session.stage(fileId)); } public async stageAll(): Promise<void> { await this.runQuietOperation(() => this.session.stageAll()); } public async unstage(fileId: string): Promise<void> { await this.runQuietOperation(() => this.session.unstage(fileId)); } public async unstageAll(): Promise<void> { await this.runQuietOperation(() => this.session.unstageAll()); } public async commitSelected(message: string): Promise<void> { await this.runSessionOperation( () => this.session.commitSelected(message), "Selected encrypted changes committed locally.", ); } public async fetchRemote(): Promise<void> { await this.runSessionOperation( () => this.session.fetchRemote(), "Remote encrypted branch checked.", ); } public async pullRemote(): Promise<void> { await this.runSessionOperation( () => this.session.pullRemote(), "Remote encrypted changes pulled.", ); } public async pushRemote(): Promise<void> { await this.runSessionOperation( () => this.session.pushRemote(), "Local encrypted commits pushed.", ); } public async showDiff(fileId: string, target: DiffTarget): Promise<void> { await this.runQuietOperation(async () => { const diff = await this.session.getDiff(fileId, target); await openSourceDiffView(this.plugin.app, diff); }); } public async openFile(path: string): Promise<void> { const file = this.plugin.app.vault.getFileByPath(path); if (file === null) { new Notice("The selected file is not available in the working vault."); return; } await this.plugin.app.workspace.getLeaf("tab").openFile(file); } private registerCommands(): void { this.plugin.addCommand({ id: "open-source-control", name: "Open Source Control", callback: () => { void this.openSourceControl(); }, }); this.plugin.addCommand({ id: "create-encrypted-vault", name: "Create encrypted vault", checkCallback: (checking) => this.availableWhen(checking, !this.session.getState().hasKey, () => { void this.createVault(); }), }); this.plugin.addCommand({ id: "unlock", name: "Unlock", checkCallback: (checking) => this.availableWhen(checking, !this.session.getState().hasKey, () => { void this.unlock(); }), }); this.plugin.addCommand({ id: "fetch-remote", name: "Fetch remote changes", checkCallback: (checking) => this.availableWhen(checking, this.session.getState().hasKey, () => { void this.fetchRemote(); }), }); this.plugin.addCommand({ id: "pull-remote", name: "Pull remote changes", checkCallback: (checking) => this.availableWhen(checking, this.session.getState().hasKey, () => { void this.pullRemote(); }), }); this.plugin.addCommand({ id: "push-remote", name: "Push local commits", checkCallback: (checking) => this.availableWhen(checking, this.session.getState().hasKey, () => { void this.pushRemote(); }), }); this.plugin.addCommand({ id: "stage-current-file", name: "Stage current file", checkCallback: (checking) => this.availableWhen( checking, this.session.getState().hasKey && this.plugin.app.workspace.getActiveFile() !== null, () => { void this.stageCurrentFile(false); }, ), }); this.plugin.addCommand({ id: "unstage-current-file", name: "Unstage current file", checkCallback: (checking) => this.availableWhen( checking, this.session.getState().hasKey && this.plugin.app.workspace.getActiveFile() !== null, () => { void this.stageCurrentFile(true); }, ), }); this.plugin.addCommand({ id: "show-tracked-files", name: "Show tracked files", checkCallback: (checking) => this.availableWhen(checking, this.session.getState().hasKey, () => { void this.showTrackedFiles(); }), }); this.plugin.addCommand({ id: "lock", name: "Lock", checkCallback: (checking) => this.availableWhen(checking, this.session.getState().hasKey, () => { void this.lock(); }), }); this.plugin.addCommand({ id: "change-password", name: "Change password", checkCallback: (checking) => this.availableWhen(checking, this.session.getState().hasKey, () => { void this.changePassword(); }), }); } private availableWhen(checking: boolean, available: boolean, action: () => void): boolean { if (!checking && available) action(); return available; } private async createVault(): Promise<void> { const password = await openCreateVaultModal(this.plugin.app); if (password === undefined) return; await this.runSessionOperation( () => this.session.createVault(password), "Encrypted vault created and unlocked.", ); } public async unlock(): Promise<void> { if (this.unlockPromptOpen) return; this.unlockPromptOpen = true; try { const password = await openUnlockModal(this.plugin.app); if (password === undefined) return; await this.runSessionOperation( () => this.session.unlock(password), "Encrypted vault unlocked.", ); } finally { this.unlockPromptOpen = false; } } private async changePassword(): Promise<void> { const password = await openChangePasswordModal(this.plugin.app); if (password === undefined) return; await this.runSessionOperation( () => this.session.changePassword(password), "Encrypted vault password changed.", ); } private async openSourceControl(): Promise<void> { let leaf = this.plugin.app.workspace.getLeavesOfType(ENCRYPTED_GIT_SOURCE_CONTROL_VIEW)[0]; leaf ??= this.plugin.app.workspace.getRightLeaf(false) ?? undefined; if (leaf === undefined) { new Notice("Unable to open Encrypted Git Source Control."); return; } await leaf.setViewState({ type: ENCRYPTED_GIT_SOURCE_CONTROL_VIEW, active: true }); await this.plugin.app.workspace.revealLeaf(leaf); } private async stageCurrentFile(unstage: boolean): Promise<void> { const active = this.plugin.app.workspace.getActiveFile(); if (active === null) return; const status = await this.getSourceControlStatus(); const change = (unstage ? status?.staged : status?.changes)?.find( (candidate) => candidate.path === active.path, ); if (change === undefined) { new Notice(unstage ? "Current file is not staged." : "Current file has no unstaged changes."); return; } if (unstage) await this.unstage(change.fileId); else await this.stage(change.fileId); } private notifyRepositoryChange(): void { for (const listener of this.repositoryListeners) { try { listener(); } catch { // UI observers cannot change repository state. } } } private async runSessionOperation<T>( operation: () => Promise<T>, successMessage: string, ): Promise<T | undefined> { try { const result = await operation(); new Notice(successMessage); return result; } catch { const message = this.session.getState().message; new Notice(message ?? "Encrypted vault operation failed. Check settings and retry."); return undefined; } } private async runQuietOperation<T>(operation: () => Promise<T>): Promise<T | undefined> { try { return await operation(); } catch (error) { const message = error instanceof SessionError ? error.message : this.session.getState().message; new Notice(message ?? "Encrypted Git operation failed. Check settings and retry."); return undefined; } } } function assertLexicallySeparated(first: string, second: string): void { const relative = path.relative(path.resolve(first), path.resolve(second)); const reverse = path.relative(path.resolve(second), path.resolve(first)); if (isInside(relative) || isInside(reverse)) { throw new SessionError("Plaintext vault, local state, and encrypted repository must not overlap"); } } function isInside(relative: string): boolean { return relative === "" || (!relative.startsWith(`..${path.sep}`) && relative !== ".." && !path.isAbsolute(relative)); } function assertStatePathDoesNotEnterSyncScope(vaultRoot: string, statePath: string): void { const relative = path.relative(path.resolve(vaultRoot), path.resolve(statePath)); if ( isInside(relative) && relative !== ".obsidian" && !relative.startsWith(`.obsidian${path.sep}`) ) { throw new SessionError("Local state inside the vault must be stored below .obsidian"); } }