From 83499c92e8ddec59aa2340fac7c2c05262290c3b Mon Sep 17 00:00:00 2001 From: ClaudiaFang Date: Tue, 14 Jul 2026 10:40:12 +0000 Subject: [PATCH] feat(ui): show connection status in the global status bar Move connection testing/state from the settings tab into a shared plugin-level store (GitLabFilesPush.connectionStatus, onConnectionStatusChange, testConnection) so a new status bar item can reflect the same in-flight test instead of racing a second one. The settings tab badge now subscribes to the shared state and unsubscribes on hide(). Status bar shows service name + state, tooltip carries the error detail, and clicking retests the connection. --- src/main.ts | 90 ++++++++++++++++++++++- src/settings.ts | 68 +++++++---------- styles.css | 30 ++++++++ tests/ui/SettingsConnectionStatus.test.ts | 29 +++++++- 4 files changed, 173 insertions(+), 44 deletions(-) diff --git a/src/main.ts b/src/main.ts index d5ca022..459571d 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,9 +1,10 @@ -import { Plugin, TFile, MarkdownView, Notice, Platform, setTooltip } from 'obsidian'; +import { Plugin, TFile, MarkdownView, Notice, Platform, setTooltip, setIcon } from 'obsidian'; import { DEFAULT_SETTINGS, GitLabFilesPushSettings, GitLabSyncSettingTab, getServiceName } from "./settings"; import { GitLabService } from './services/gitlab-service'; import { GitHubService } from './services/github-service'; import { GiteaService } from './services/gitea-service'; import { GitServiceInterface, GitTreeEntry } from './services/git-service-interface'; +import { ConnectionTestResult } from './services/git-service-base'; import { SyncManager } from './logic/sync-manager'; import { SyncStatusView, SYNC_STATUS_VIEW_TYPE } from './ui/SyncStatusView'; import { GitignoreManager } from './logic/gitignore-manager'; @@ -14,12 +15,23 @@ import { CHANGELOG, getUnseenReleases } from './changelog'; import { compareVersions } from './utils/version'; import { t } from './i18n'; +export type ConnectionStatusState = 'checking' | 'connected' | 'disconnected'; + +export interface ConnectionStatus { + state: ConnectionStatusState; + detail?: string; +} + export default class GitLabFilesPush extends Plugin { settings: GitLabFilesPushSettings; gitService: GitServiceInterface; sync: SyncManager; gitignoreManager: GitignoreManager; private pushRibbonEl: HTMLElement; + private statusBarEl: HTMLElement; + connectionStatus: ConnectionStatus = { state: 'checking' }; + private connectionStatusListeners: Set<(status: ConnectionStatus) => void> = new Set(); + private connectionTestSeq = 0; async onload() { await this.loadSettings(); @@ -46,6 +58,13 @@ export default class GitLabFilesPush extends Plugin { this.gitignoreManager = new GitignoreManager(this.app, this.gitService, this.settings.branch, this.settings.rootPath, this.settings.vaultFolder, this.settings.ignorePatterns); this.sync = new SyncManager(this.app, this.gitService, this.settings, this.saveSettings.bind(this)); + this.statusBarEl = this.addStatusBarItem(); + this.statusBarEl.addClass('gfs-status-bar-connection'); + setTooltip(this.statusBarEl, t('settings.connectionStatus.checking')); + this.registerDomEvent(this.statusBarEl, 'click', () => void this.testConnection()); + this.onConnectionStatusChange((status) => this.renderStatusBarConnection(status)); + void this.testConnection(); + this.pushRibbonEl = this.addRibbonIcon('upload-cloud', this.pushRibbonLabel(), async () => { const activeView = this.app.workspace.getActiveViewOfType(MarkdownView); if (activeView && activeView.file instanceof TFile) { @@ -144,6 +163,75 @@ export default class GitLabFilesPush extends Plugin { return getServiceName(this.settings); } + // Subscribes to connection status changes, immediately replaying the current + // status so late subscribers (e.g. the settings tab opened after the initial + // test already ran) don't have to wait for the next change. Returns an + // unsubscribe function. + onConnectionStatusChange(listener: (status: ConnectionStatus) => void): () => void { + this.connectionStatusListeners.add(listener); + listener(this.connectionStatus); + return () => this.connectionStatusListeners.delete(listener); + } + + private setConnectionStatus(status: ConnectionStatus): void { + this.connectionStatus = status; + for (const listener of this.connectionStatusListeners) listener(status); + } + + // Single source of truth for connection testing, shared by the settings tab + // badge and the status bar item so both reflect the same in-flight request + // instead of racing separate calls against the remote API. + async testConnection(): Promise { + const seq = ++this.connectionTestSeq; + this.setConnectionStatus({ state: 'checking' }); + + try { + const result = await this.gitService.testConnection(this.settings.branch); + if (seq !== this.connectionTestSeq) return result; + + if (!result.repoOk) { + this.setConnectionStatus({ state: 'disconnected', detail: result.error ?? t('settings.testConnection.failed.unreachable') }); + } else if (!result.branchOk) { + this.setConnectionStatus({ state: 'disconnected', detail: t('settings.testConnection.branchNotFound.badge', { branch: this.settings.branch }) }); + } else { + this.setConnectionStatus({ state: 'connected' }); + } + return result; + } catch (e: unknown) { + if (seq === this.connectionTestSeq) { + const message = e instanceof Error ? e.message : String(e); + this.setConnectionStatus({ state: 'disconnected', detail: message }); + } + throw e; + } + } + + private renderStatusBarConnection(status: ConnectionStatus): void { + const el = this.statusBarEl; + if (!el) return; + el.empty(); + el.removeClass('is-checking', 'is-connected', 'is-disconnected'); + el.addClass(`is-${status.state}`); + + const icons: Record = { + checking: 'loader', + connected: 'check-circle', + disconnected: 'alert-circle', + }; + setIcon(el.createSpan({ cls: 'gfs-status-bar-icon' }), icons[status.state]); + + const labels: Record = { + checking: t('settings.connectionStatus.checking'), + connected: t('settings.connectionStatus.connected'), + disconnected: t('settings.connectionStatus.disconnected'), + }; + el.createSpan({ text: ` ${this.serviceName}: ${labels[status.state]}` }); + + setTooltip(el, status.detail + ? t('settings.connectionStatus.withDetail', { label: labels[status.state], detail: status.detail }) + : labels[status.state]); + } + private pushRibbonLabel(): string { return Platform.isMobile ? t('main.ribbon.push') : t('main.ribbon.pushTo', { service: this.serviceName }); } diff --git a/src/settings.ts b/src/settings.ts index 1390512..fba6861 100644 --- a/src/settings.ts +++ b/src/settings.ts @@ -1,8 +1,7 @@ import {App, PluginSettingTab, Setting, Notice, TextComponent} from 'obsidian'; -import GitLabFilesPush from "./main"; +import GitLabFilesPush, { type ConnectionStatus } from "./main"; import {FolderSuggest} from "./ui/FolderSuggest"; import {RemoteFolderSuggest} from "./ui/RemoteFolderSuggest"; -import { ConnectionTestResult } from "./services/git-service-base"; import { t } from "./i18n"; // Minimal shape of Obsidian >= 1.13's SettingDefinitionItem. Declared locally so @@ -94,21 +93,31 @@ export const DEFAULT_SETTINGS: GitLabFilesPushSettings = { lastSeenVersion: '' } -type ConnectionStatusState = 'checking' | 'connected' | 'disconnected'; - const CONNECTION_TEST_DEBOUNCE_MS = 800; export class GitLabSyncSettingTab extends PluginSettingTab { plugin: GitLabFilesPush; private statusBadgeEl: HTMLElement | null = null; private connectionTestTimer: ReturnType | null = null; - private connectionTestSeq = 0; + private unsubscribeConnectionStatus: (() => void) | null = null; constructor(app: App, plugin: GitLabFilesPush) { super(app, plugin); this.plugin = plugin; } + // The status badge mirrors the plugin's shared connection status (also + // driving the status bar item) instead of running its own test, so both + // stay in sync and don't race separate requests against the remote API. + hide(): void { + this.unsubscribeConnectionStatus?.(); + this.unsubscribeConnectionStatus = null; + if (this.connectionTestTimer) { + clearTimeout(this.connectionTestTimer); + this.connectionTestTimer = null; + } + } + // Kept as a fallback for Obsidian < 1.13.0 (older than 1.13, down to // minAppVersion 1.11.0), which don't know about getSettingDefinitions() // and always call display(). @@ -140,28 +149,28 @@ export class GitLabSyncSettingTab extends PluginSettingTab { // Rebuilding the whole settings tab (renderSettings) to refresh the badge // would empty and recreate every field, stealing focus mid-typing. The // badge element is instead created once per renderSettings pass and - // updated in place by setStatusBadge(). + // updated in place by setStatusBadge(), driven by the plugin's shared + // connection status (see main.ts) so it stays in sync with the status bar. private renderConnectionStatus(containerEl: HTMLElement): void { this.statusBadgeEl = containerEl.createDiv({ cls: 'gfs-connection-status' }); - this.setStatusBadge('checking'); + this.unsubscribeConnectionStatus?.(); + this.unsubscribeConnectionStatus = this.plugin.onConnectionStatusChange((status) => this.setStatusBadge(status)); } - private setStatusBadge(state: ConnectionStatusState, detail?: string): void { + private setStatusBadge(status: ConnectionStatus): void { const badge = this.statusBadgeEl; if (!badge) return; - badge.removeClass('is-checking'); - badge.removeClass('is-connected'); - badge.removeClass('is-disconnected'); - badge.addClass(`is-${state}`); + badge.removeClass('is-checking', 'is-connected', 'is-disconnected'); + badge.addClass(`is-${status.state}`); - const labels: Record = { + const labels: Record = { checking: t('settings.connectionStatus.checking'), connected: t('settings.connectionStatus.connected'), disconnected: t('settings.connectionStatus.disconnected') }; - const label = labels[state]; - badge.setText(detail ? t('settings.connectionStatus.withDetail', { label, detail }) : label); + const label = labels[status.state]; + badge.setText(status.detail ? t('settings.connectionStatus.withDetail', { label, detail: status.detail }) : label); } // Debounced so token/branch fields (which call this on every keystroke) @@ -172,35 +181,10 @@ export class GitLabSyncSettingTab extends PluginSettingTab { } this.connectionTestTimer = setTimeout(() => { this.connectionTestTimer = null; - void this.testConnectionSilently(); + void this.plugin.testConnection(); }, CONNECTION_TEST_DEBOUNCE_MS); } - private async testConnectionSilently(): Promise { - const seq = ++this.connectionTestSeq; - this.setStatusBadge('checking'); - - try { - const result = await this.plugin.gitService.testConnection(this.plugin.settings.branch); - if (seq !== this.connectionTestSeq) return result; - - if (!result.repoOk) { - this.setStatusBadge('disconnected', result.error ?? t('settings.testConnection.failed.unreachable')); - } else if (!result.branchOk) { - this.setStatusBadge('disconnected', t('settings.testConnection.branchNotFound.badge', { branch: this.plugin.settings.branch })); - } else { - this.setStatusBadge('connected'); - } - return result; - } catch (e: unknown) { - if (seq === this.connectionTestSeq) { - const message = e instanceof Error ? e.message : String(e); - this.setStatusBadge('disconnected', message); - } - throw e; - } - } - private renderSettings(containerEl: HTMLElement): void { containerEl.empty(); @@ -310,7 +294,7 @@ export class GitLabSyncSettingTab extends PluginSettingTab { .setButtonText(t('settings.testConnection.button')) .onClick(async () => { try { - const result = await this.testConnectionSilently(); + const result = await this.plugin.testConnection(); if (!result.repoOk) { new Notice(t('settings.testConnection.failed', { reason: result.error ?? t('settings.testConnection.failed.unreachable') })); } else if (!result.branchOk) { diff --git a/styles.css b/styles.css index aec6684..f5e7ca1 100644 --- a/styles.css +++ b/styles.css @@ -605,6 +605,36 @@ background: var(--background-modifier-error); } +/* ── Global status bar connection indicator ────────────────────── */ +.gfs-status-bar-connection { + display: flex; + align-items: center; + gap: 4px; + cursor: pointer; +} + +.gfs-status-bar-icon { + display: inline-flex; + align-items: center; +} + +.gfs-status-bar-icon svg { + width: 12px; + height: 12px; +} + +.gfs-status-bar-connection.is-checking { + color: var(--text-muted); +} + +.gfs-status-bar-connection.is-connected { + color: var(--text-success); +} + +.gfs-status-bar-connection.is-disconnected { + color: var(--text-error); +} + /* ── Conflict Modal ─────────────────────────────────────────────── */ .sync-conflict-modal.modal { width: min(1100px, 92vw); diff --git a/tests/ui/SettingsConnectionStatus.test.ts b/tests/ui/SettingsConnectionStatus.test.ts index 7365861..dea319a 100644 --- a/tests/ui/SettingsConnectionStatus.test.ts +++ b/tests/ui/SettingsConnectionStatus.test.ts @@ -3,6 +3,7 @@ import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; import { App } from 'obsidian'; import { DEFAULT_SETTINGS, GitLabSyncSettingTab } from '../../src/settings'; import GitLabFilesPush from '../../src/main'; +import type { ConnectionTestResult } from '../../src/services/git-service-base'; import { createContainer, setupObsidianDOM } from './setup-dom'; vi.mock('../../src/main', () => ({ @@ -11,12 +12,38 @@ vi.mock('../../src/main', () => ({ beforeAll(() => { setupObsidianDOM(); }); -function createPluginStub(testConnection: ReturnType): GitLabFilesPush { +// Mirrors the connection-status pub/sub that main.ts owns (onConnectionStatusChange +// / testConnection), since src/main is mocked out above and the settings tab now +// delegates to the plugin instead of running its own connection test. +function createPluginStub(testConnection: () => Promise): GitLabFilesPush { + let connectionStatus: { state: string; detail?: string } = { state: 'checking' }; + const listeners = new Set<(status: typeof connectionStatus) => void>(); + return { settings: { ...DEFAULT_SETTINGS }, saveSettings: vi.fn().mockResolvedValue(undefined), initializeGitService: vi.fn(), gitService: { testConnection }, + onConnectionStatusChange: vi.fn((listener: (status: typeof connectionStatus) => void) => { + listeners.add(listener); + listener(connectionStatus); + return () => listeners.delete(listener); + }), + testConnection: vi.fn(async () => { + connectionStatus = { state: 'checking' }; + for (const listener of listeners) listener(connectionStatus); + + const result = await testConnection(); + if (!result.repoOk) { + connectionStatus = { state: 'disconnected', detail: result.error ?? 'unreachable' }; + } else if (!result.branchOk) { + connectionStatus = { state: 'disconnected', detail: 'branch not found' }; + } else { + connectionStatus = { state: 'connected' }; + } + for (const listener of listeners) listener(connectionStatus); + return result; + }), } as unknown as GitLabFilesPush; }