diff --git a/main.ts b/main.ts index b6d0f2c..f958457 100644 --- a/main.ts +++ b/main.ts @@ -1,4 +1,4 @@ -import { Menu, MenuItem, Notice, Plugin, TFile } from 'obsidian'; +import { Menu, MenuItem, Notice, Plugin, TAbstractFile, TFile } from 'obsidian'; import { GitHubService } from './src/services/githubService'; import { SyncService, PersistedSyncState, SyncResult } from './src/services/syncService'; import { LoggerService } from './src/services/loggerService'; @@ -6,6 +6,7 @@ import { DiffView, DIFF_VIEW_TYPE } from './src/views/DiffView'; import { SyncView, SYNC_VIEW_TYPE } from './src/views/SyncView'; import { GitHubOctokitSettingTab } from './src/ui'; import { GitHubOctokitSettings, DEFAULT_SETTINGS, AdditionalRepoConfig, VaultRepoConfig, VAULT_REPOS_CONFIG_PATH } from './src/types/settings'; +import { normalizePath } from './src/utils/fileUtils'; /** Per-repo runtime state for additional repositories */ export interface AdditionalRepoRuntime { @@ -188,6 +189,33 @@ export default class GitHubOctokitPlugin extends Plugin { } }); + // File context menu: Open on GitHub / Copy GitHub link + this.registerEvent( + this.app.workspace.on('file-menu', (menu, file: TAbstractFile) => { + if (!(file instanceof TFile)) return; + const resolved = this.resolveGitHubUrl(file.path); + if (!resolved) return; + + menu.addItem((item: MenuItem) => { + item.setTitle('Open on GitHub') + .setIcon('external-link') + .onClick(() => { + window.open(resolved, '_blank'); + }); + }); + + menu.addItem((item: MenuItem) => { + item.setTitle('GitHub link') + .setIcon('github') + .setSection('info.copy') + .onClick(() => { + void navigator.clipboard.writeText(resolved); + new Notice('GitHub link copied to clipboard'); + }); + }); + }) + ); + // This adds a settings tab this.addSettingTab(new GitHubOctokitSettingTab(this.app, this)); @@ -272,6 +300,45 @@ export default class GitHubOctokitPlugin extends Plugin { return patterns; } + // ======================================================================== + // GitHub URL helpers + // ======================================================================== + + /** + * Convert a local vault path to the corresponding remote repo path, + * accounting for subfolderPath mapping. + */ + toRepoPath(localPath: string, subfolderPath: string): string { + if (!subfolderPath || subfolderPath === '/') { + return localPath; + } + return normalizePath(`${subfolderPath}/${localPath}`); + } + + /** + * Resolve the repo config (main or additional) that owns a given vault path. + * Returns the GitHub blob URL, or null if no repo is configured. + */ + resolveGitHubUrl(vaultPath: string): string | null { + if (!this.githubService.isAuthenticated) return null; + + // Check additional repos first (more specific paths) + for (const repoConfig of this.settings.additionalRepos) { + if (!repoConfig.enabled || !repoConfig.localPath) continue; + const prefix = repoConfig.localPath + '/'; + if (vaultPath === repoConfig.localPath || vaultPath.startsWith(prefix)) { + const relativePath = vaultPath.slice(prefix.length); + const repoPath = this.toRepoPath(relativePath, repoConfig.subfolderPath); + return `https://github.com/${repoConfig.owner}/${repoConfig.repo}/blob/${repoConfig.branch}/${repoPath}`; + } + } + + // Fall back to main repo + if (!this.settings.repo) return null; + const repoPath = this.toRepoPath(vaultPath, this.settings.subfolderPath); + return `https://github.com/${this.settings.repo.owner}/${this.settings.repo.name}/blob/${this.settings.repo.branch}/${repoPath}`; + } + /** * Initialize additional repo services and authenticate them */ diff --git a/src/views/SyncView.ts b/src/views/SyncView.ts index 6a7dba5..d6daec2 100644 --- a/src/views/SyncView.ts +++ b/src/views/SyncView.ts @@ -2,7 +2,7 @@ import { ItemView, WorkspaceLeaf, Notice, TFile } from 'obsidian'; import type GitHubOctokitPlugin from '../../main'; import { FileSyncState } from '../services/syncService'; import { LogEntry } from '../services/loggerService'; -import { decodeBase64, normalizePath } from '../utils/fileUtils'; +import { decodeBase64 } from '../utils/fileUtils'; import { confirmDestructiveAction } from '../ui/modals/confirmDialog'; export const SYNC_VIEW_TYPE = 'github-octokit-sync-view'; @@ -34,11 +34,7 @@ export class SyncView extends ItemView { } private toRepoPath(path: string): string { - const subfolderPath = this.plugin.settings.subfolderPath; - if (!subfolderPath || subfolderPath === '/') { - return path; - } - return normalizePath(`${subfolderPath}/${path}`); + return this.plugin.toRepoPath(path, this.plugin.settings.subfolderPath); } /** Restore persisted UI state from vault-specific localStorage */ @@ -355,12 +351,11 @@ export class SyncView extends ItemView { }); // Open in GitHub - if (this.plugin.settings.repo) { + const ghUrl = this.plugin.resolveGitHubUrl(file.path); + if (ghUrl) { const ghBtn = actions.createEl('button', { text: 'GitHub', cls: 'file-action' }); ghBtn.addEventListener('click', () => { - const repoPath = this.toRepoPath(file.path); - const url = `https://github.com/${this.plugin.settings.repo!.owner}/${this.plugin.settings.repo!.name}/blob/${this.plugin.settings.repo!.branch}/${repoPath}`; - window.open(url, '_blank'); + window.open(ghUrl, '_blank'); }); } } diff --git a/tests/views/SyncView.test.ts b/tests/views/SyncView.test.ts index db4eef8..8d45995 100644 --- a/tests/views/SyncView.test.ts +++ b/tests/views/SyncView.test.ts @@ -1,5 +1,6 @@ import { App } from 'obsidian'; import { SyncView } from '../../src/views/SyncView'; +import { normalizePath } from '../../src/utils/fileUtils'; describe('SyncView - Branch-aware remote reads', () => { it('passes the configured branch when loading remote content for diff', async () => { @@ -25,6 +26,10 @@ describe('SyncView - Branch-aware remote reads', () => { }, githubService, openDiffView: jest.fn().mockResolvedValue(null), + toRepoPath(localPath: string, subfolderPath: string): string { + if (!subfolderPath || subfolderPath === '/') return localPath; + return normalizePath(`${subfolderPath}/${localPath}`); + }, }; const view = Object.create(SyncView.prototype) as SyncView & { @@ -68,6 +73,10 @@ describe('SyncView - Branch-aware remote reads', () => { }, githubService, openDiffView: jest.fn().mockResolvedValue(null), + toRepoPath(localPath: string, subfolderPath: string): string { + if (!subfolderPath || subfolderPath === '/') return localPath; + return normalizePath(`${subfolderPath}/${localPath}`); + }, }; const view = Object.create(SyncView.prototype) as SyncView & { @@ -111,6 +120,10 @@ describe('SyncView - Branch-aware remote reads', () => { }, githubService, openDiffView: jest.fn().mockResolvedValue(null), + toRepoPath(localPath: string, subfolderPath: string): string { + if (!subfolderPath || subfolderPath === '/') return localPath; + return normalizePath(`${subfolderPath}/${localPath}`); + }, }; const view = Object.create(SyncView.prototype) as SyncView & {