mirror of
https://github.com/firstsun-dev/git-files-sync.git
synced 2026-07-22 06:54:27 +00:00
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:
parent
d8e3663b8f
commit
83499c92e8
4 changed files with 173 additions and 44 deletions
90
src/main.ts
90
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<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 });
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
30
styles.css
30
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);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue