diff --git a/CHANGELOG.md b/CHANGELOG.md index 663a8b5..f2ec490 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,9 @@ version with its date and start a fresh empty `[Unreleased]` above it. - The agent now learns which external context directories are attached: the message you send right after adding or removing one carries the current list, so the agent can use those folders without you referencing every file. +- An update icon appears in the view header when GitHub has a newer stable + release; click it to open Qoderian's plugin page, where Obsidian's update + button lives. ## [1.0.11] - 2026-09-16 diff --git a/src/features/chat/chat-view.ts b/src/features/chat/chat-view.ts index 7807455..7ac8ed6 100644 --- a/src/features/chat/chat-view.ts +++ b/src/features/chat/chat-view.ts @@ -15,6 +15,7 @@ import { setButtonTooltip } from '../../shared/dom/tooltip'; import { createIconSvg, QODER_ICON,QODERIAN_ICON_ID } from '../../shared/icons'; import { openFeedbackModal } from '../feedback/ui/feedback-modal'; import { QoderianSettingsModal } from '../settings/settings-modal'; +import { openQoderianUpdatePage } from '../update/plugin-update-checker'; import type { HistoryConversationStatus } from './controllers/conversation-controller'; import { sendTabInputMessageFromExplicitEnterShortcut, @@ -46,6 +47,7 @@ export class QoderianView extends ItemView { // DOM Elements private viewContainerEl: HTMLElement | null = null; private logoEl: HTMLElement | null = null; + private updateBadgeEl: HTMLElement | null = null; private newTabButtonEl: HTMLElement | null = null; private newConversationButtonEl: HTMLElement | null = null; private historyButtonEl: HTMLElement | null = null; @@ -227,6 +229,13 @@ export class QoderianView extends ItemView { titleEl.createEl('h4', { text: 'Qoder', cls: 'qoderian-title-text' }); const headerActions = header.createDiv({ cls: 'qoderian-header-actions' }); + this.updateBadgeEl = headerActions.createEl('button', { + cls: 'qoderian-header-btn qoderian-update-badge qoderian-hidden', + attr: { type: 'button' }, + }); + setIcon(this.updateBadgeEl, 'download'); + void this.refreshUpdateBadge(); + const feedbackBtn = headerActions.createDiv({ cls: 'qoderian-header-btn' }); setIcon(feedbackBtn, 'message-circle-question'); setButtonTooltip(feedbackBtn, t('commands.submitFeedback')); @@ -317,6 +326,20 @@ export class QoderianView extends ItemView { new QoderianSettingsModal(this.plugin).open(); } + /** Shows the badge once GitHub reports a newer stable release. */ + private async refreshUpdateBadge(): Promise { + const badge = this.updateBadgeEl; + if (!badge) return; + + const update = await this.plugin.getAvailableUpdate(); + if (!update || this.updateBadgeEl !== badge) return; + + badge.dataset.version = update.version; + setButtonTooltip(badge, t('updates.available', { version: update.version })); + badge.removeClass('qoderian-hidden'); + badge.addEventListener('click', () => openQoderianUpdatePage(this.plugin.manifest.id)); + } + private buildInputFooter(): void { if (!this.viewContainerEl) return; @@ -387,6 +410,12 @@ export class QoderianView extends ItemView { setButtonTooltip(this.newConversationButtonEl, t('nav.newConversation')); } if (this.historyButtonEl) setButtonTooltip(this.historyButtonEl, t('nav.chatHistory')); + if (this.updateBadgeEl?.dataset.version) { + setButtonTooltip( + this.updateBadgeEl, + t('updates.available', { version: this.updateBadgeEl.dataset.version }), + ); + } this.creditsUsageButton?.refreshLocale(); for (const tab of this.tabManager?.getAllTabs() ?? []) { tab.ui.composerResize?.refreshLocale(); diff --git a/src/features/update/plugin-update-checker.ts b/src/features/update/plugin-update-checker.ts new file mode 100644 index 0000000..eac0851 --- /dev/null +++ b/src/features/update/plugin-update-checker.ts @@ -0,0 +1,69 @@ +import { requestUrl } from 'obsidian'; + +import { openExternalBrowserUrl } from '../../qoder/services/qoder-login-service'; + +const LATEST_RELEASE_API_URL = 'https://api.github.com/repos/QoderAI/Qoderian/releases/latest'; + +export interface QoderianUpdate { + version: string; + url: string; +} + +interface GitHubReleaseResponse { + html_url?: unknown; + tag_name?: unknown; +} + +function parseVersion(value: string): number[] | null { + const match = value.trim().replace(/^v/i, '').match(/^(\d+)\.(\d+)\.(\d+)(?:[-+].*)?$/); + if (!match) return null; + return match.slice(1).map(Number); +} + +/** Semver comparison for the three-part release tags used by Qoderian. */ +export function isNewerQoderianVersion(currentVersion: string, latestVersion: string): boolean { + const current = parseVersion(currentVersion); + const latest = parseVersion(latestVersion); + if (!current || !latest) return false; + + for (let index = 0; index < latest.length; index += 1) { + if (latest[index] !== current[index]) return latest[index] > current[index]; + } + return false; +} + +/** Obsidian's own URI for a plugin's entry in the community-plugins list. */ +export function qoderianPluginPageUri(pluginId: string): string { + return `obsidian://show-plugin?id=${encodeURIComponent(pluginId)}`; +} + +/** + * Opens Obsidian's plugin page, where the update button lives. Obsidian + * handles its own URI scheme, so this stays inside the app. + */ +export function openQoderianUpdatePage(pluginId: string): void { + openExternalBrowserUrl(qoderianPluginPageUri(pluginId)); +} + +/** + * Checks the latest stable GitHub release. Network and malformed-response + * failures are intentionally silent so update discovery never blocks chat. + */ +export async function fetchAvailableQoderianUpdate( + currentVersion: string, +): Promise { + try { + const response = await requestUrl({ + url: LATEST_RELEASE_API_URL, + headers: { Accept: 'application/vnd.github+json' }, + }); + const release = response.json as GitHubReleaseResponse; + if (typeof release.tag_name !== 'string' || typeof release.html_url !== 'string') return null; + + const version = release.tag_name.replace(/^v/i, ''); + if (!isNewerQoderianVersion(currentVersion, version)) return null; + return { version, url: release.html_url }; + } catch { + return null; + } +} diff --git a/src/i18n/locales/de.json b/src/i18n/locales/de.json index 9bd4df8..b9ac998 100644 --- a/src/i18n/locales/de.json +++ b/src/i18n/locales/de.json @@ -400,5 +400,8 @@ "thinkingEffort": "Thinking Effort", "default": "Standard", "backToList": "Zurück zur Modellliste" + }, + "updates": { + "available": "Update {version}" } } diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index 7ad8910..e08640c 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -400,5 +400,8 @@ "thinkingEffort": "Thinking Effort", "default": "Default", "backToList": "Back to model list" + }, + "updates": { + "available": "Update {version}" } } diff --git a/src/i18n/locales/es.json b/src/i18n/locales/es.json index 3cfbb6b..92e82a8 100644 --- a/src/i18n/locales/es.json +++ b/src/i18n/locales/es.json @@ -400,5 +400,8 @@ "thinkingEffort": "Thinking Effort", "default": "Predeterminado", "backToList": "Volver a la lista de modelos" + }, + "updates": { + "available": "Actualizar {version}" } } diff --git a/src/i18n/locales/fr.json b/src/i18n/locales/fr.json index db35c7a..2d54d69 100644 --- a/src/i18n/locales/fr.json +++ b/src/i18n/locales/fr.json @@ -400,5 +400,8 @@ "thinkingEffort": "Thinking Effort", "default": "Par défaut", "backToList": "Retour à la liste des modèles" + }, + "updates": { + "available": "Mise à jour {version}" } } diff --git a/src/i18n/locales/ja.json b/src/i18n/locales/ja.json index f805751..b13f374 100644 --- a/src/i18n/locales/ja.json +++ b/src/i18n/locales/ja.json @@ -400,5 +400,8 @@ "thinkingEffort": "Thinking Effort", "default": "デフォルト", "backToList": "モデルリストに戻る" + }, + "updates": { + "available": "更新 {version}" } } diff --git a/src/i18n/locales/ko.json b/src/i18n/locales/ko.json index d91904c..c5957b9 100644 --- a/src/i18n/locales/ko.json +++ b/src/i18n/locales/ko.json @@ -400,5 +400,8 @@ "thinkingEffort": "Thinking Effort", "default": "기본", "backToList": "모델 목록으로 돌아가기" + }, + "updates": { + "available": "업데이트 {version}" } } diff --git a/src/i18n/locales/pt.json b/src/i18n/locales/pt.json index 3a1a4cd..9260a2f 100644 --- a/src/i18n/locales/pt.json +++ b/src/i18n/locales/pt.json @@ -400,5 +400,8 @@ "thinkingEffort": "Thinking Effort", "default": "Padrão", "backToList": "Voltar à lista de modelos" + }, + "updates": { + "available": "Atualizar {version}" } } diff --git a/src/i18n/locales/ru.json b/src/i18n/locales/ru.json index 49f95d1..77f93f6 100644 --- a/src/i18n/locales/ru.json +++ b/src/i18n/locales/ru.json @@ -400,5 +400,8 @@ "thinkingEffort": "Thinking Effort", "default": "По умолчанию", "backToList": "Назад к списку моделей" + }, + "updates": { + "available": "Обновить {version}" } } diff --git a/src/i18n/locales/zh-CN.json b/src/i18n/locales/zh-CN.json index fcad0eb..08892fd 100644 --- a/src/i18n/locales/zh-CN.json +++ b/src/i18n/locales/zh-CN.json @@ -400,5 +400,8 @@ "thinkingEffort": "Thinking Effort", "default": "默认", "backToList": "返回模型列表" + }, + "updates": { + "available": "更新 {version}" } } diff --git a/src/i18n/locales/zh-TW.json b/src/i18n/locales/zh-TW.json index a810c7e..efcbd48 100644 --- a/src/i18n/locales/zh-TW.json +++ b/src/i18n/locales/zh-TW.json @@ -400,5 +400,8 @@ "thinkingEffort": "Thinking Effort", "default": "預設", "backToList": "返回模型列表" + }, + "updates": { + "available": "更新 {version}" } } diff --git a/src/i18n/types.ts b/src/i18n/types.ts index be549b1..2b396e4 100644 --- a/src/i18n/types.ts +++ b/src/i18n/types.ts @@ -344,4 +344,7 @@ export type TranslationKey = // Settings - Language | 'settings.language.name' - | 'settings.language.desc'; + | 'settings.language.desc' + + // Plugin updates + | 'updates.available' diff --git a/src/main.ts b/src/main.ts index c092353..a2afd17 100644 --- a/src/main.ts +++ b/src/main.ts @@ -24,6 +24,10 @@ import { QoderianView } from './features/chat/chat-view'; import { openFeedbackModal } from './features/feedback/ui/feedback-modal'; import { type InlineEditContext, InlineEditModal } from './features/inline-edit/ui/modal'; import { QoderianSettingTab } from './features/settings/settings-tab'; +import { + fetchAvailableQoderianUpdate, + type QoderianUpdate, +} from './features/update/plugin-update-checker'; import { setLocale, t } from './i18n/i18n'; import type { Locale } from './i18n/types'; import { getActiveQoderCliEdition, setActiveQoderCliEdition } from './qoder/config/cli-edition'; @@ -51,6 +55,13 @@ export default class QoderianPlugin extends Plugin { qoderServices!: QoderServices; private conversations: Conversation[] = []; private lastKnownTabManagerState: AppTabManagerState | null = null; + private updateCheckPromise: Promise | null = null; + + /** Checks once per plugin session so reopening the view does not hit GitHub repeatedly. */ + getAvailableUpdate(): Promise { + this.updateCheckPromise ??= fetchAvailableQoderianUpdate(this.manifest.version); + return this.updateCheckPromise; + } async onload() { await this.loadSettings(); diff --git a/src/style/components/header.css b/src/style/components/header.css index 45523d4..5b65bc0 100644 --- a/src/style/components/header.css +++ b/src/style/components/header.css @@ -26,6 +26,28 @@ color: var(--qoderian-brand); } +/* Update badge: an icon button in the header actions, shown only while a newer + release exists. The doubled class keeps it clear of the theme's default + button chrome without !important. */ +.qoderian-header-actions .qoderian-update-badge.qoderian-update-badge { + padding: 0; + height: auto; + min-height: 0; + border: none; + border-radius: 3px; + background: transparent; + box-shadow: none; + color: var(--qoderian-brand); +} + +.qoderian-header-actions .qoderian-update-badge.qoderian-update-badge:hover, +.qoderian-header-actions .qoderian-update-badge.qoderian-update-badge:focus-visible { + color: var(--qoderian-brand); + background: rgba(var(--qoderian-brand-rgb), 0.14); + box-shadow: none; + outline: none; +} + .qoderian-header-actions { display: flex; align-items: center; diff --git a/tests/__mocks__/obsidian.ts b/tests/__mocks__/obsidian.ts index e27e9b0..1211d14 100644 --- a/tests/__mocks__/obsidian.ts +++ b/tests/__mocks__/obsidian.ts @@ -465,3 +465,5 @@ export class TFolder { this.name = path.split('/').pop() || ''; } } + +export const requestUrl = jest.fn(); diff --git a/tests/unit/features/update/plugin-update-checker.test.ts b/tests/unit/features/update/plugin-update-checker.test.ts new file mode 100644 index 0000000..4005b48 --- /dev/null +++ b/tests/unit/features/update/plugin-update-checker.test.ts @@ -0,0 +1,51 @@ +import { requestUrl } from 'obsidian'; + +import { + fetchAvailableQoderianUpdate, + isNewerQoderianVersion, + qoderianPluginPageUri, +} from '@/features/update/plugin-update-checker'; + +const mockRequestUrl = requestUrl as jest.Mock; + +describe('plugin update checker', () => { + beforeEach(() => { + mockRequestUrl.mockReset(); + }); + + it.each([ + ['1.0.7', '1.0.8', true], + ['1.0.7', '1.1.0', true], + ['1.9.9', '2.0.0', true], + ['1.0.7', '1.0.7', false], + ['1.0.8', '1.0.7', false], + ['invalid', '1.0.8', false], + ])('compares %s with %s', (current, latest, expected) => { + expect(isNewerQoderianVersion(current, latest)).toBe(expected); + }); + + it('returns the newer stable GitHub release', async () => { + mockRequestUrl.mockResolvedValue({ + json: { + tag_name: 'v1.1.0', + html_url: 'https://github.com/QoderAI/Qoderian/releases/tag/v1.1.0', + }, + }); + + await expect(fetchAvailableQoderianUpdate('1.0.7')).resolves.toEqual({ + version: '1.1.0', + url: 'https://github.com/QoderAI/Qoderian/releases/tag/v1.1.0', + }); + }); + + it('builds the plugin page URI Obsidian itself handles', () => { + expect(qoderianPluginPageUri('qoderian')).toBe('obsidian://show-plugin?id=qoderian'); + expect(qoderianPluginPageUri('weird id/name')).toBe('obsidian://show-plugin?id=weird%20id%2Fname'); + }); + + it('fails silently when offline', async () => { + mockRequestUrl.mockRejectedValue(new Error('offline')); + + await expect(fetchAvailableQoderianUpdate('1.0.7')).resolves.toBeNull(); + }); +});