/
raiden
/
obsidian-git-encrypt
Обзор
Документация
Войти
/
raiden
/
obsidian-git-encrypt
Код
Запросы
0
Задачи
Вики
Пакеты
0
Релизы
0
CI/CD
Аналитика
Безопасность
master
src/ui/sourceControlView.ts
604 строки
22 KB
Robert Kuzhin
feat: add manual encrypted source control
09 авг 2026, 20:57
09 авг 2026, 20:57
3c8fa38
Код
Авторство
О чём код?
import { ItemView, Scope, setIcon, type WorkspaceLeaf } from "obsidian"; import type { DiffTarget, SourceControlStatus, SourceFileChange, } from "../sourceControl/index.js"; export const ENCRYPTED_GIT_SOURCE_CONTROL_VIEW = "encrypted-git-source-control"; export interface SourceControlViewController { isUnlocked(): boolean; getDefaultCommitMessage(): string; getBranchName(): string; getSourceControlStatus(): Promise<SourceControlStatus | undefined>; stage(fileId: string): Promise<void>; stageAll(): Promise<void>; unstage(fileId: string): Promise<void>; unstageAll(): Promise<void>; commitSelected(message: string): Promise<void>; fetchRemote(): Promise<void>; pullRemote(): Promise<void>; pushRemote(): Promise<void>; showDiff(fileId: string, target: DiffTarget): Promise<void>; openFile(path: string): Promise<void>; subscribeRepository(listener: () => void): () => void; } /** * Source-control layout adapted from Vinzent03/obsidian-git. * See THIRD_PARTY_NOTICES.md. All actions are routed through the encrypted * source-control controller rather than Git paths in the plaintext vault. */ export class EncryptedGitSourceControlView extends ItemView { private unsubscribe: (() => void) | undefined; private refreshGeneration = 0; private commitMessage = ""; private status: SourceControlStatus | undefined; private stagedOpen = true; private changesOpen = true; private incomingOpen = true; private busy = false; public constructor( leaf: WorkspaceLeaf, private readonly controller: SourceControlViewController, ) { super(leaf); } public getViewType(): string { return ENCRYPTED_GIT_SOURCE_CONTROL_VIEW; } public getDisplayText(): string { return "Encrypted Git Source Control"; } public override getIcon(): string { return "git-pull-request"; } public override async onOpen(): Promise<void> { this.commitMessage = this.controller.getDefaultCommitMessage(); this.scope = new Scope(this.app.scope); this.scope.register(["Mod"], "Enter", () => { void this.runAction(() => this.commit()); return false; }); this.unsubscribe = this.controller.subscribeRepository(() => { void this.refresh(); }); await this.refresh(); } public override onClose(): Promise<void> { this.unsubscribe?.(); this.unsubscribe = undefined; this.contentEl.empty(); return Promise.resolve(); } private async refresh(): Promise<void> { const generation = ++this.refreshGeneration; if (!this.controller.isUnlocked()) { this.status = undefined; this.renderMessage("Unlock the encrypted vault to inspect and commit changes."); return; } this.setLoading(true); if (this.status === undefined) this.renderMessage("Reading encrypted repository state…"); const status = await this.controller.getSourceControlStatus(); if (generation !== this.refreshGeneration) return; this.setLoading(false); if (status === undefined) { if (this.contentEl.querySelector(".encrypted-git-git-view") !== null) return; this.renderMessage("Unable to read source control state."); return; } this.status = status; this.render(status); } private render(status: SourceControlStatus): void { const renderState = this.captureRenderState(); this.contentEl.empty(); this.contentEl.addClass("encrypted-git-source-control"); const view = this.contentEl.createEl("main", { cls: "encrypted-git-git-view" }); this.renderToolbar(view, status); this.renderCommitMessage(view, status); if (status.conflicts > 0) { const conflicts = view.createEl("div", { cls: "encrypted-git-source-conflicts" }); conflicts.createEl("strong", { text: `Conflicts (${status.conflicts})` }); conflicts.createEl("p", { text: "Pull again to review each conflict explicitly." }); } const files = view.createEl("div", { cls: "nav-files-container encrypted-git-nav-files-container", }); const stagedIds = new Set(status.staged.map((change) => change.fileId)); this.renderSection(files, { title: "Staged Changes", changes: status.staged, open: this.stagedOpen, onToggle: () => { this.stagedOpen = !this.stagedOpen; this.render(status); }, actionIcon: "minus", actionLabel: "Unstage all", onAction: () => this.controller.unstageAll(), staged: true, stagedIds, }); this.renderSection(files, { title: "Changes", changes: status.changes, open: this.changesOpen, onToggle: () => { this.changesOpen = !this.changesOpen; this.render(status); }, actionIcon: "plus", actionLabel: "Stage all", onAction: () => this.controller.stageAll(), staged: false, stagedIds, }); if (status.incoming.length > 0) { this.renderSection(files, { title: "Incoming Changes", changes: status.incoming, open: this.incomingOpen, onToggle: () => { this.incomingOpen = !this.incomingOpen; this.render(status); }, staged: undefined, stagedIds, }); } if (status.staged.length === 0 && status.changes.length === 0) { files.createEl("p", { cls: "encrypted-git-source-empty", text: "No local changes." }); } if (this.busy) this.setBusy(true); this.restoreRenderState(renderState); } private renderToolbar(container: HTMLElement, status: SourceControlStatus): void { const header = container.createEl("div", { cls: "nav-header encrypted-git-nav-header" }); const repository = header.createEl("div", { cls: "encrypted-git-repository-state" }); const branch = repository.createEl("span", { cls: "encrypted-git-branch-name", text: this.controller.getBranchName(), attr: { title: `HEAD ${status.headCommit.slice(0, 8)}` }, }); const branchIcon = branch.createSpan({ cls: "encrypted-git-inline-icon" }); branch.prepend(branchIcon); setIcon(branchIcon, "git-branch"); repository.createEl("span", { text: status.remoteKnown ? `${status.behind} incoming · ${status.ahead} outgoing` : status.ahead > 0 ? `${status.ahead} outgoing · Not fetched` : "Not fetched", }); const buttons = header.createEl("div", { cls: "nav-buttons-container" }); const remoteActions = buttons.createEl("div", { cls: "encrypted-git-toolbar-group", attr: { "aria-label": "Remote operations", role: "group" }, }); this.addToolbarButton( remoteActions, "cloud-download", "Fetch", () => this.controller.fetchRemote(), true, ); const pull = this.addToolbarButton( remoteActions, "download", `Pull${status.behind > 0 ? ` (${status.behind})` : ""}`, () => this.controller.pullRemote(), true, ); if (status.behind > 0) pull.addClass("is-pending"); const push = this.addToolbarButton( remoteActions, "upload", `Push${status.ahead > 0 ? ` (${status.ahead})` : ""}`, () => this.controller.pushRemote(), true, ); if (status.ahead > 0) push.addClass("is-pending"); const viewActions = buttons.createEl("div", { cls: "encrypted-git-toolbar-group", attr: { "aria-label": "View operations", role: "group" }, }); const refresh = this.addToolbarButton( viewActions, "refresh-cw", "Refresh", () => Promise.resolve(), ); refresh.dataset.action = "refresh"; } private renderCommitMessage(container: HTMLElement, status: SourceControlStatus): void { const commit = container.createEl("div", { cls: "encrypted-git-commit-msg" }); const textarea = commit.createEl("textarea", { cls: "encrypted-git-commit-message", attr: { placeholder: "Commit message", maxlength: "200", rows: String(Math.min(5, Math.max(1, this.commitMessage.split("\n").length))), spellcheck: "true", "aria-label": "Commit message", }, }); textarea.dataset.focusKey = "commit-message"; textarea.value = this.commitMessage; textarea.addEventListener("input", () => { this.commitMessage = textarea.value; const button = this.contentEl.querySelector<HTMLButtonElement>("[data-action='commit']"); if (button !== null) { button.disabled = this.busy || status.staged.length === 0 || this.commitMessage.trim().length === 0; } }); if (this.commitMessage.length > 0) { const clear = commit.createEl("button", { cls: "clickable-icon encrypted-git-commit-clear", attr: { "aria-label": "Clear commit message" }, }); clear.dataset.focusKey = "commit-clear"; setIcon(clear, "x"); clear.addEventListener("click", () => { this.commitMessage = ""; textarea.value = ""; const button = this.contentEl.querySelector<HTMLButtonElement>("[data-action='commit']"); if (button !== null) button.disabled = true; textarea.focus(); }); } const footer = commit.createEl("div", { cls: "encrypted-git-commit-footer" }); const hint = footer.createEl("div", { cls: "encrypted-git-commit-hint", attr: { title: "Commit messages are Git metadata and are not encrypted." }, }); const hintIcon = hint.createSpan({ cls: "encrypted-git-inline-icon" }); setIcon(hintIcon, "unlock"); hint.createSpan({ text: "Commit message is visible in Git history" }); const commitButton = footer.createEl("button", { cls: "mod-cta encrypted-git-commit-button", attr: { "aria-label": commitButtonLabel(status.staged.length) }, }); const commitIcon = commitButton.createSpan({ cls: "encrypted-git-inline-icon" }); setIcon(commitIcon, "check"); commitButton.createSpan({ text: commitButtonLabel(status.staged.length) }); commitButton.dataset.action = "commit"; commitButton.dataset.focusKey = "commit"; commitButton.disabled = status.staged.length === 0 || this.commitMessage.trim().length === 0; commitButton.addEventListener("click", () => { void this.runAction(() => this.commit()); }); } private renderSection(container: HTMLElement, options: SectionOptions): void { const section = container.createEl("div", { cls: `tree-item nav-folder encrypted-git-change-section${options.open ? "" : " is-collapsed"}`, }); const heading = section.createEl("div", { cls: "tree-item-self is-clickable nav-folder-title", }); const toggle = heading.createEl("button", { cls: "encrypted-git-section-toggle", attr: { "aria-expanded": String(options.open), "aria-label": `${options.title}, ${options.changes.length}` }, }); toggle.dataset.focusKey = `section:${options.title}:toggle`; const collapse = toggle.createEl("span", { cls: `tree-item-icon nav-folder-collapse-indicator collapse-icon${options.open ? "" : " is-collapsed"}`, }); setIcon(collapse, "chevron-down"); toggle.createEl("span", { cls: "tree-item-inner nav-folder-title-content", text: options.title }); const tools = heading.createEl("div", { cls: "encrypted-git-section-tools" }); tools.createEl("span", { cls: "encrypted-git-section-count", text: String(options.changes.length), attr: { "aria-label": `${options.changes.length} files` }, }); const buttons = tools.createEl("div", { cls: "encrypted-git-section-buttons" }); if ( options.actionIcon !== undefined && options.actionLabel !== undefined && options.onAction !== undefined ) { const action = buttons.createEl("button", { cls: "clickable-icon encrypted-git-section-action", attr: { "aria-label": options.actionLabel }, }); action.dataset.focusKey = `section:${options.title}:action`; setIcon(action, options.actionIcon); action.disabled = options.changes.length === 0; action.addEventListener("click", (event) => { event.stopPropagation(); void this.runAction(options.onAction!); }); } toggle.addEventListener("click", options.onToggle); if (!options.open) return; const children = section.createEl("div", { cls: "tree-item-children nav-folder-children encrypted-git-section-children", }); for (const change of options.changes) { this.renderChange(children, change, options.staged, options.stagedIds); } } private renderChange( container: HTMLElement, change: SourceFileChange, staged: boolean | undefined, stagedIds: ReadonlySet<string>, ): void { const changedAfterStaging = staged === false && stagedIds.has(change.fileId); const target: DiffTarget = staged === true ? "staged" : changedAfterStaging ? "after-staging" : "working"; const item = container.createEl("div", { cls: "tree-item nav-file" }); const row = item.createEl("div", { cls: `tree-item-self nav-file-title encrypted-git-change-row${changedAfterStaging ? " is-after-staging" : ""}`, }); if (staged === undefined) { const path = row.createEl("div", { cls: "tree-item-inner nav-file-title-content", }); this.renderChangePath(path, change, false); } else { const path = row.createEl("button", { cls: "tree-item-inner nav-file-title-content encrypted-git-file-diff", attr: { "aria-label": `Show diff for ${change.path}` }, }); path.dataset.focusKey = `file:${change.fileId}:diff:${target}`; this.renderChangePath(path, change, changedAfterStaging); path.addEventListener("click", () => { void this.controller.showDiff(change.fileId, target); }); } const tools = row.createEl("div", { cls: "encrypted-git-row-tools" }); const buttons = tools.createEl("div", { cls: "encrypted-git-row-actions" }); if (staged !== undefined && change.kind !== "deleted") { this.addRowButton( buttons, "go-to-file", "Open file", `file:${change.fileId}:open:${target}`, undefined, () => this.controller.openFile(change.path), ); } if (staged !== undefined) { const actionIcon = changedAfterStaging ? "refresh-cw" : staged ? "minus" : "plus"; const actionLabel = changedAfterStaging ? "Update staged snapshot" : staged ? "Unstage" : "Stage"; this.addRowButton( buttons, actionIcon, actionLabel, `file:${change.fileId}:selection:${target}`, `file:${change.fileId}:selection`, () => staged ? this.controller.unstage(change.fileId) : this.controller.stage(change.fileId), ); } tools.createEl("div", { cls: "encrypted-git-change-kind", text: kindLabel(change.kind), attr: { "data-type": kindLabel(change.kind), "aria-label": change.kind }, }); } private renderChangePath( container: HTMLElement, change: SourceFileChange, changedAfterStaging: boolean, ): void { const path = describePath(change); container.createSpan({ cls: "encrypted-git-path-primary", text: path.primary }); if (path.context.length === 0 && !changedAfterStaging) return; const meta = container.createSpan({ cls: "encrypted-git-path-meta" }); if (path.context.length > 0) { meta.createSpan({ cls: "encrypted-git-path-context", text: path.context }); } if (!changedAfterStaging) return; meta.createSpan({ cls: "encrypted-git-after-staging-badge", text: "Changed after staging", attr: { "aria-label": "Working copy changed after staging", }, }); } private addToolbarButton( container: HTMLElement, icon: string, label: string, action: () => Promise<unknown>, showLabel = false, ): HTMLButtonElement { const button = container.createEl("button", { cls: `clickable-icon nav-action-button encrypted-git-nav-action${showLabel ? " is-labeled" : ""}`, attr: { "aria-label": label }, }); const iconElement = button.createSpan({ cls: "encrypted-git-inline-icon" }); setIcon(iconElement, icon); if (showLabel) button.createSpan({ text: label }); button.dataset.focusKey = `toolbar:${icon}`; button.addEventListener("click", () => { void this.runAction(action); }); return button; } private addRowButton( container: HTMLElement, icon: string, label: string, focusKey: string, focusFallbackKey: string | undefined, action: () => Promise<unknown>, ): void { const button = container.createEl("button", { cls: "clickable-icon", attr: { "aria-label": label }, }); button.dataset.focusKey = focusKey; if (focusFallbackKey !== undefined) button.dataset.focusFallbackKey = focusFallbackKey; setIcon(button, icon); button.addEventListener("click", (event) => { event.stopPropagation(); void this.runAction(action); }); } private async commit(): Promise<void> { const message = this.commitMessage.trim(); if (message.length === 0 || (this.status?.staged.length ?? 0) === 0) { return; } await this.controller.commitSelected(message); this.commitMessage = this.controller.getDefaultCommitMessage(); } private async runAction(action: () => Promise<unknown>): Promise<void> { if (this.busy) return; this.busy = true; this.setBusy(true); this.setLoading(true); try { await action(); await this.refresh(); } finally { this.busy = false; this.setBusy(false); } } private setBusy(busy: boolean): void { this.contentEl.toggleClass("is-busy", busy); this.contentEl.setAttr("aria-busy", String(busy)); for (const button of Array.from(this.contentEl.querySelectorAll<HTMLButtonElement>("button"))) { if (busy) { button.dataset.encryptedGitWasDisabled ??= String(button.disabled); button.disabled = true; continue; } const wasDisabled = button.dataset.encryptedGitWasDisabled; if (wasDisabled === undefined) continue; button.disabled = wasDisabled === "true"; delete button.dataset.encryptedGitWasDisabled; } this.setLoading(busy); } private setLoading(loading: boolean): void { const refresh = this.contentEl.querySelector<HTMLElement>("[data-action='refresh']"); refresh?.toggleClass("is-loading", loading); } private renderMessage(message: string): void { this.contentEl.empty(); this.contentEl.addClass("encrypted-git-source-control"); this.contentEl.createEl("p", { cls: "encrypted-git-source-empty", text: message }); } private captureRenderState(): RenderState { const scrollTop = this.contentEl.querySelector<HTMLElement>(".encrypted-git-nav-files-container")?.scrollTop ?? 0; const active = this.contentEl.ownerDocument.activeElement; const focusKey = active?.getAttribute("data-focus-key") ?? undefined; const focusFallbackKey = active?.getAttribute("data-focus-fallback-key") ?? undefined; if (active?.matches("textarea.encrypted-git-commit-message") !== true) { if (focusKey === undefined) return { scrollTop }; return focusFallbackKey === undefined ? { scrollTop, focusKey } : { scrollTop, focusKey, focusFallbackKey }; } const textarea = active as HTMLTextAreaElement; return { scrollTop, commitSelection: [textarea.selectionStart, textarea.selectionEnd], }; } private restoreRenderState(state: RenderState): void { const files = this.contentEl.querySelector<HTMLElement>(".encrypted-git-nav-files-container"); if (files !== null) files.scrollTop = state.scrollTop; if (state.commitSelection !== undefined) { const textarea = this.contentEl.querySelector<HTMLTextAreaElement>(".encrypted-git-commit-message"); if (textarea === null) return; textarea.focus({ preventScroll: true }); textarea.setSelectionRange(...state.commitSelection); return; } if (state.focusKey === undefined) return; const candidates = Array.from( this.contentEl.querySelectorAll<HTMLElement>("[data-focus-key]"), ); const focusTarget = candidates.find((element) => element.dataset.focusKey === state.focusKey) ?? (state.focusFallbackKey === undefined ? undefined : candidates.find( (element) => element.dataset.focusFallbackKey === state.focusFallbackKey, )); focusTarget?.focus({ preventScroll: true }); } } interface RenderState { readonly scrollTop: number; readonly focusKey?: string; readonly focusFallbackKey?: string; readonly commitSelection?: readonly [number, number]; } interface SectionOptions { readonly title: string; readonly changes: readonly SourceFileChange[]; readonly open: boolean; readonly onToggle: () => void; readonly actionIcon?: string; readonly actionLabel?: string; readonly onAction?: () => Promise<unknown>; readonly staged: boolean | undefined; readonly stagedIds: ReadonlySet<string>; } function describePath(change: SourceFileChange): { readonly primary: string; readonly context: string } { const current = splitPath(change.path); if (change.oldPath === undefined) return { primary: current.name, context: current.parent }; const previous = splitPath(change.oldPath); return { primary: `${previous.name} → ${current.name}`, context: previous.parent === current.parent ? current.parent : `${previous.parent || "Vault root"} → ${current.parent || "Vault root"}`, }; } function splitPath(path: string): { readonly name: string; readonly parent: string } { const segments = path.split("/"); const name = segments.pop() ?? path; return { name, parent: segments.length === 0 ? "" : `${segments.join("/")}/` }; } function commitButtonLabel(count: number): string { if (count === 0) return "No staged files"; return count === 1 ? "Commit 1 file" : `Commit ${count} files`; } function kindLabel(kind: SourceFileChange["kind"]): string { const labels: Record<SourceFileChange["kind"], string> = { added: "A", modified: "M", renamed: "R", deleted: "D", }; return labels[kind]; }