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.
This commit is contained in:
ClaudiaFang 2026-07-14 10:40:12 +00:00
parent d8e3663b8f
commit 83499c92e8
4 changed files with 173 additions and 44 deletions

View file

@ -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<ConnectionTestResult> {
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<ConnectionStatusState, string> = {
checking: 'loader',
connected: 'check-circle',
disconnected: 'alert-circle',
};
setIcon(el.createSpan({ cls: 'gfs-status-bar-icon' }), icons[status.state]);
const labels: Record<ConnectionStatusState, string> = {
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 });
}

View file

@ -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<typeof setTimeout> | 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<ConnectionStatusState, string> = {
const labels: Record<ConnectionStatus['state'], string> = {
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<ConnectionTestResult> {
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) {

View file

@ -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);

View file

@ -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<typeof vi.fn>): 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<ConnectionTestResult>): 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;
}