diff --git a/extension/src/service-worker/__tests__/vault-status.test.ts b/extension/src/service-worker/__tests__/vault-status.test.ts new file mode 100644 index 0000000..c9497d8 --- /dev/null +++ b/extension/src/service-worker/__tests__/vault-status.test.ts @@ -0,0 +1,53 @@ +import { describe, expect, it } from 'vitest'; +import { handleGetVaultStatus } from '../vault'; +import type { Manifest, ManifestEntry } from '../../shared/types'; + +// The handler only reads gitHost's three cache fields, so the test feeds a +// minimal object — the handler's Pick-typed param makes full GitHost mocking +// unnecessary. +const cache = (lastSyncAt: number | null, ahead = 0, behind = 0) => + ({ lastSyncAt, ahead, behind }); + +function manifestWith(activeCount: number, trashedCount = 0): Manifest { + const items: Record = {}; + for (let i = 0; i < activeCount; i++) { + items[`a${i}`] = { trashed_at: undefined } as ManifestEntry; + } + for (let i = 0; i < trashedCount; i++) { + items[`t${i}`] = { trashed_at: 1000 } as ManifestEntry; + } + return { items } as Manifest; +} + +describe('handleGetVaultStatus', () => { + it('returns zeros when never synced and no manifest', () => { + const resp = handleGetVaultStatus({ gitHost: cache(null), manifest: null }); + expect(resp).toEqual({ + ok: true, + data: { ahead: 0, behind: 0, lastSyncAt: null, pendingItems: 0 }, + }); + }); + + it('reflects cached sync state + active (non-trashed) item count', () => { + const resp = handleGetVaultStatus({ + gitHost: cache(1234567890, 3, 1), + manifest: manifestWith(5, 2), + }); + expect(resp.ok).toBe(true); + if (resp.ok) { + expect(resp.data).toEqual({ + ahead: 3, behind: 1, lastSyncAt: 1234567890, pendingItems: 5, + }); + } + }); + + it('returns vault_locked error when gitHost is null', () => { + expect(handleGetVaultStatus({ gitHost: null, manifest: null })) + .toEqual({ ok: false, error: 'vault_locked' }); + }); + + it('is synchronous — no network round-trip', () => { + const resp = handleGetVaultStatus({ gitHost: cache(0), manifest: null }); + expect(resp).not.toBeInstanceOf(Promise); + }); +}); diff --git a/extension/src/service-worker/git-host.ts b/extension/src/service-worker/git-host.ts index d817e81..45c6fa9 100644 --- a/extension/src/service-worker/git-host.ts +++ b/extension/src/service-worker/git-host.ts @@ -41,6 +41,15 @@ export interface GitHost { /// Delete a blob from the repo. Currently identical to deleteFile; /// kept distinct for symmetry with putBlob. deleteBlob(path: string, message: string): Promise; + + /// Cached sync metadata, populated by the `sync` handler — get_vault_status + /// reads these without any network call. lastSyncAt is unix SECONDS (or null + /// until the first sync). ahead/behind exist for parity with `relicario + /// status`; the extension writes straight to the host (no local commit + /// graph), so in practice they stay 0. + lastSyncAt: number | null; + ahead: number; + behind: number; } /// Pre-base64 byte size at which putBlob switches from Contents API to diff --git a/extension/src/service-worker/gitea.ts b/extension/src/service-worker/gitea.ts index e888bdd..d51a931 100644 --- a/extension/src/service-worker/gitea.ts +++ b/extension/src/service-worker/gitea.ts @@ -20,6 +20,9 @@ export class GiteaHost implements GitHost { private keysUrl: string; private branch: string = 'main'; private headers: Record; + lastSyncAt: number | null = null; + ahead = 0; + behind = 0; constructor(hostUrl: string, repoPath: string, apiToken: string) { // Remove trailing slash from hostUrl diff --git a/extension/src/service-worker/github.ts b/extension/src/service-worker/github.ts index e1a3d56..85bf198 100644 --- a/extension/src/service-worker/github.ts +++ b/extension/src/service-worker/github.ts @@ -17,6 +17,9 @@ export class GitHubHost implements GitHost { private commitsUrl: string; private branch: string = 'main'; private headers: Record; + lastSyncAt: number | null = null; + ahead = 0; + behind = 0; constructor(repoPath: string, apiToken: string) { this.baseUrl = `https://api.github.com/repos/${repoPath}/contents`; diff --git a/extension/src/service-worker/router/popup-only.ts b/extension/src/service-worker/router/popup-only.ts index 8df97e9..bbc6036 100644 --- a/extension/src/service-worker/router/popup-only.ts +++ b/extension/src/service-worker/router/popup-only.ts @@ -59,6 +59,9 @@ export async function handle( case 'lock': session.clearCurrent(); state.manifest = null; + // Don't leak the cached git-host (incl. lastSyncAt) across a lock — + // symmetric with the session-expiry path (index.ts); unlock rebuilds it. + state.gitHost = null; return { ok: true }; case 'list_items': { @@ -130,6 +133,8 @@ export async function handle( const handle = session.getCurrent(); if (!handle || !state.gitHost) return { ok: false, error: 'vault_locked' }; state.manifest = await vault.fetchAndDecryptManifest(state.gitHost, handle); + // Record sync time (unix SECONDS) for the get_vault_status indicator. + state.gitHost.lastSyncAt = Math.floor(Date.now() / 1000); return { ok: true }; } @@ -634,9 +639,9 @@ export async function handle( case 'attach_vault': return vault.handleAttachVault(msg, state); - // get_vault_status lands in Phase 6 (Dev-C). - // Until each case lands, an unhandled popup message returns an explicit - // error rather than falling through with no return. + case 'get_vault_status': + return vault.handleGetVaultStatus(state); + default: return { ok: false, error: `unhandled popup message: ${(msg as { type: string }).type}` }; } diff --git a/extension/src/service-worker/vault.ts b/extension/src/service-worker/vault.ts index d12cb89..ad91ad3 100644 --- a/extension/src/service-worker/vault.ts +++ b/extension/src/service-worker/vault.ts @@ -7,7 +7,7 @@ import { createGitHost, uint8ArrayToBase64 } from './git-host'; import type { AttachmentRef, Item, ItemId, Manifest, ManifestEntry, VaultSettings, VaultConfig } from '../shared/types'; import * as session from './session'; import * as devices from './devices'; -import type { AttachVaultResponse, CreateVaultResponse } from '../shared/messages'; +import type { AttachVaultResponse, CreateVaultResponse, GetVaultStatusResponse } from '../shared/messages'; import type { PopupState } from './router/popup-only'; // eslint-disable-next-line @typescript-eslint/no-explicit-any @@ -548,3 +548,32 @@ export function itemToManifestEntry(item: Item): ManifestEntry { function safeHostname(url: string): string | undefined { try { return new URL(url).hostname; } catch { return undefined; } } + +// --- Vault status (Plan C Phase 6) --- + +/** + * Return the cached vault status for the sidebar indicator. Reads cached sync + * metadata off the GitHost (populated by the `sync` handler) plus a live count + * of active (non-trashed) items from the in-memory manifest. Does NOT touch + * the network — sync is user-initiated (spec 2026-05-04, Phase 6). The + * Pick-typed gitHost param both avoids a circular import of the router's + * PopupState and structurally forbids a network call from here. + */ +export function handleGetVaultStatus( + state: { + gitHost: Pick | null; + manifest: Manifest | null; + }, +): GetVaultStatusResponse | { ok: false; error: string } { + if (!state.gitHost) return { ok: false, error: 'vault_locked' }; + const pendingItems = state.manifest ? listItems(state.manifest).length : 0; + return { + ok: true, + data: { + ahead: state.gitHost.ahead, + behind: state.gitHost.behind, + lastSyncAt: state.gitHost.lastSyncAt, + pendingItems, + }, + }; +} diff --git a/extension/src/shared/glyphs.ts b/extension/src/shared/glyphs.ts index 1a4c6b9..2b45f2e 100644 --- a/extension/src/shared/glyphs.ts +++ b/extension/src/shared/glyphs.ts @@ -19,6 +19,11 @@ export const GLYPH_LOCK = '⏻'; // sidebar lock nav export const GLYPH_NEXT = '▸'; // forward / next button (matches ▾/▸ disclosure family) export const GLYPH_COPY = '⎘'; // copy to clipboard export const GLYPH_SYNC = '⇅'; // sync / upload +export const GLYPH_REFRESH = '↻'; // manual refresh (vault status indicator); shares ↻ with GENERATE, distinct semantic +export const GLYPH_SYNCED = '✓'; // vault status: in sync (no pending/ahead/behind) +export const GLYPH_AHEAD = '↑'; // vault status: local commits ahead of remote +export const GLYPH_BEHIND = '↓'; // vault status: remote commits not yet pulled +export const GLYPH_PENDING = '◌'; // vault status: items changed but not yet synced export const GLYPH_PREVIEW = '⊕'; // preview / expand export const GLYPH_VAULT_TAB = '⧉'; // U+29C9 pop-out to fullscreen vault tab diff --git a/extension/src/vault/__tests__/status-indicator.test.ts b/extension/src/vault/__tests__/status-indicator.test.ts new file mode 100644 index 0000000..d31c581 --- /dev/null +++ b/extension/src/vault/__tests__/status-indicator.test.ts @@ -0,0 +1,34 @@ +import { describe, expect, it } from 'vitest'; +import { renderStatusIndicator } from '../vault-status'; + +describe('vault status indicator', () => { + it('renders "in sync" when ahead/behind/pending all zero', () => { + const el = document.createElement('div'); + renderStatusIndicator(el, { ahead: 0, behind: 0, lastSyncAt: 1700000000, pendingItems: 0 }); + expect(el.textContent).toMatch(/in sync/i); + }); + + it('renders "N ahead" when ahead is non-zero', () => { + const el = document.createElement('div'); + renderStatusIndicator(el, { ahead: 3, behind: 0, lastSyncAt: 1700000000, pendingItems: 0 }); + expect(el.textContent).toMatch(/3 ahead/i); + }); + + it('renders "N behind" when behind is non-zero', () => { + const el = document.createElement('div'); + renderStatusIndicator(el, { ahead: 0, behind: 2, lastSyncAt: 1700000000, pendingItems: 0 }); + expect(el.textContent).toMatch(/2 behind/i); + }); + + it('renders "N pending" when pendingItems is non-zero', () => { + const el = document.createElement('div'); + renderStatusIndicator(el, { ahead: 0, behind: 0, lastSyncAt: 1700000000, pendingItems: 5 }); + expect(el.textContent).toMatch(/5 pending/i); + }); + + it('renders "never synced" when lastSyncAt is null', () => { + const el = document.createElement('div'); + renderStatusIndicator(el, { ahead: 0, behind: 0, lastSyncAt: null, pendingItems: 0 }); + expect(el.textContent).toMatch(/never synced/i); + }); +}); diff --git a/extension/src/vault/__tests__/status-integration.test.ts b/extension/src/vault/__tests__/status-integration.test.ts new file mode 100644 index 0000000..197abb7 --- /dev/null +++ b/extension/src/vault/__tests__/status-integration.test.ts @@ -0,0 +1,65 @@ +import { describe, expect, it } from 'vitest'; +import { handleGetVaultStatus } from '../../service-worker/vault'; +import { renderStatusIndicator } from '../vault-status'; +import type { Manifest, ManifestEntry } from '../../shared/types'; + +// Integration seam: the get_vault_status SW handler (Phase 6 Task 6.1) produces +// the exact data shape the sidebar renderer (Task 6.2) consumes. This pins the +// contract between the two so a future change to either side can't silently +// drift the keys apart. It does NOT touch vault-sidebar.ts — the wiring layer +// (Task 6.3) is Dev-B's boundary and lands separately. + +const cache = (lastSyncAt: number | null, ahead = 0, behind = 0) => + ({ lastSyncAt, ahead, behind }); + +function manifestWith(activeCount: number, trashedCount = 0): Manifest { + const items: Record = {}; + for (let i = 0; i < activeCount; i++) { + items[`a${i}`] = { trashed_at: undefined } as ManifestEntry; + } + for (let i = 0; i < trashedCount; i++) { + items[`t${i}`] = { trashed_at: 1000 } as ManifestEntry; + } + return { items } as Manifest; +} + +describe('vault status: handler → renderer integration', () => { + it('renders "in sync" from a freshly-synced, no-pending handler response', () => { + const resp = handleGetVaultStatus({ gitHost: cache(1700000000), manifest: manifestWith(0) }); + expect(resp.ok).toBe(true); + if (!resp.ok) return; + const el = document.createElement('div'); + renderStatusIndicator(el, resp.data); + expect(el.textContent).toMatch(/in sync/i); + expect(el.textContent).toMatch(/last sync/i); + }); + + it('surfaces the handler\'s active-item count as "N pending" in the DOM', () => { + const resp = handleGetVaultStatus({ gitHost: cache(1700000000), manifest: manifestWith(7, 3) }); + expect(resp.ok).toBe(true); + if (!resp.ok) return; + expect(resp.data.pendingItems).toBe(7); + const el = document.createElement('div'); + renderStatusIndicator(el, resp.data); + expect(el.textContent).toMatch(/7 pending/i); + }); + + it('surfaces cached ahead/behind from the handler in the DOM', () => { + const resp = handleGetVaultStatus({ gitHost: cache(1700000000, 2, 1), manifest: manifestWith(0) }); + expect(resp.ok).toBe(true); + if (!resp.ok) return; + const el = document.createElement('div'); + renderStatusIndicator(el, resp.data); + expect(el.textContent).toMatch(/2 ahead/i); + expect(el.textContent).toMatch(/1 behind/i); + }); + + it('renders "never synced" when the handler reports a null lastSyncAt', () => { + const resp = handleGetVaultStatus({ gitHost: cache(null), manifest: manifestWith(0) }); + expect(resp.ok).toBe(true); + if (!resp.ok) return; + const el = document.createElement('div'); + renderStatusIndicator(el, resp.data); + expect(el.textContent).toMatch(/never synced/i); + }); +}); diff --git a/extension/src/vault/__tests__/vault-sidebar-status.test.ts b/extension/src/vault/__tests__/vault-sidebar-status.test.ts new file mode 100644 index 0000000..bdc45f5 --- /dev/null +++ b/extension/src/vault/__tests__/vault-sidebar-status.test.ts @@ -0,0 +1,62 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { renderSidebarShell, wireSidebar } from '../vault-sidebar'; +import type { VaultController } from '../vault-context'; + +const STATUS = { ahead: 0, behind: 0, lastSyncAt: 1700000000, pendingItems: 4 }; + +function makeCtx() { + return { + state: { searchQuery: '' }, + sendMessage: vi.fn(async (req: { type: string }) => + req.type === 'get_vault_status' + ? { ok: true, data: STATUS } + : { ok: true }), + render: vi.fn(), + renderPane: vi.fn(), + renderListPane: vi.fn(), + closeDrawer: vi.fn(), + openTypePanel: vi.fn(), + setHash: vi.fn(), + applyShellViewClass: vi.fn(), + } as unknown as VaultController; +} + +function statusCalls(ctx: VaultController): number { + return (ctx.sendMessage as ReturnType).mock.calls + .filter((c) => (c[0] as { type: string }).type === 'get_vault_status').length; +} + +describe('vault-sidebar status wiring', () => { + beforeEach(() => { document.body.innerHTML = renderSidebarShell(); }); + afterEach(() => { document.body.innerHTML = ''; }); + + it('fetches + renders the indicator on mount', async () => { + const ctx = makeCtx(); + wireSidebar(ctx); + await vi.waitFor(() => { + expect(document.getElementById('vault-status-slot')?.textContent).toMatch(/4 pending/i); + }); + expect(statusCalls(ctx)).toBe(1); + }); + + it('re-fetches on the manual refresh button', async () => { + const ctx = makeCtx(); + wireSidebar(ctx); + await vi.waitFor(() => expect(statusCalls(ctx)).toBe(1)); + document.getElementById('status-refresh-btn')?.dispatchEvent(new Event('click')); + await vi.waitFor(() => expect(statusCalls(ctx)).toBe(2)); + }); + + it('does NOT poll on a timer', async () => { + vi.useFakeTimers(); + try { + const ctx = makeCtx(); + wireSidebar(ctx); + await vi.advanceTimersByTimeAsync(60_000); + // Only the single mount fetch — no interval re-fetches. + expect(statusCalls(ctx)).toBe(1); + } finally { + vi.useRealTimers(); + } + }); +}); diff --git a/extension/src/vault/vault-sidebar.ts b/extension/src/vault/vault-sidebar.ts index b7db7b7..3004467 100644 --- a/extension/src/vault/vault-sidebar.ts +++ b/extension/src/vault/vault-sidebar.ts @@ -2,12 +2,14 @@ // nav-button wiring, and the (now debounced) search input. Each function // receives the VaultController (`ctx`) and reaches sibling concerns through it; // pure helpers come from vault-context. Imports only from shared/ and -// vault-context — never from vault-shell or vault.ts. +// vault-context, plus the leaf renderer vault-status — never from vault-shell +// or vault.ts. import type { ItemType } from '../shared/types'; import { - GLYPH_TRASH, GLYPH_DEVICES, GLYPH_SETTINGS, GLYPH_HISTORY, GLYPH_LOCK, + GLYPH_TRASH, GLYPH_DEVICES, GLYPH_SETTINGS, GLYPH_HISTORY, GLYPH_LOCK, GLYPH_REFRESH, } from '../shared/glyphs'; +import { renderStatusIndicator, type VaultStatus } from './vault-status'; import { type VaultController, typeIcon, typeLabel, getFilteredEntries, } from './vault-context'; @@ -38,8 +40,8 @@ export function renderSidebarShell(): string { `; } @@ -109,6 +111,20 @@ export function wireSidebar(ctx: VaultController): void { ctx.renderListPane(); } }); + + // Vault status indicator — refresh on mount + on the manual button only. + // No timer polling: get_vault_status returns cached state and sync is + // user-initiated (spec 2026-05-04, Phase 6). + const refreshStatus = async (): Promise => { + const resp = await ctx.sendMessage({ type: 'get_vault_status' }); + if (!resp.ok) return; + const slot = document.getElementById('vault-status-slot'); + if (slot) renderStatusIndicator(slot, resp.data as VaultStatus); + }; + void refreshStatus(); + document.getElementById('status-refresh-btn')?.addEventListener('click', () => { + void refreshStatus(); + }); } function isEditableTarget(target: EventTarget | null): boolean { diff --git a/extension/src/vault/vault-status.ts b/extension/src/vault/vault-status.ts new file mode 100644 index 0000000..d68a1b9 --- /dev/null +++ b/extension/src/vault/vault-status.ts @@ -0,0 +1,33 @@ +import { + GLYPH_SYNCED, + GLYPH_AHEAD, + GLYPH_BEHIND, + GLYPH_PENDING, +} from '../shared/glyphs'; +import { relativeTime } from '../shared/relative-time'; +import type { GetVaultStatusResponse } from '../shared/messages'; + +// The indicator consumes exactly the get_vault_status response payload; alias +// it (rather than re-declaring the four fields) so the shape stays single- +// sourced and can't drift from the SW handler. lastSyncAt is a unix timestamp +// in SECONDS, or null when the vault has never synced. +export type VaultStatus = GetVaultStatusResponse['data']; + +export function renderStatusIndicator(el: HTMLElement, status: VaultStatus): void { + const ts = status.lastSyncAt !== null + ? `last sync ${relativeTime(status.lastSyncAt)}` + : 'never synced'; + + const parts: string[] = []; + if (status.pendingItems > 0) parts.push(`${GLYPH_PENDING} ${status.pendingItems} pending`); + if (status.ahead > 0) parts.push(`${GLYPH_AHEAD} ${status.ahead} ahead`); + if (status.behind > 0) parts.push(`${GLYPH_BEHIND} ${status.behind} behind`); + if (parts.length === 0) parts.push(`${GLYPH_SYNCED} in sync`); + + el.innerHTML = ` +
+
${parts.join(' · ')}
+
${ts}
+
+ `; +} diff --git a/extension/src/vault/vault.css b/extension/src/vault/vault.css index 8e7ac7d..ac3f975 100644 --- a/extension/src/vault/vault.css +++ b/extension/src/vault/vault.css @@ -2113,3 +2113,39 @@ textarea { .history-index-row__info { flex: 1; display: flex; flex-direction: column; } .history-index-row__title { color: var(--text); } .history-index-row__meta { font-size: 11px; } + +/* Sidebar-footer vault status indicator (Plan C Phase 6, vault-status.ts + + vault-sidebar.ts). Indicator renders into #vault-status-slot; the ↻ button + triggers a manual refresh (no timer polling). */ +.vault-sidebar__footer { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + padding: 8px 12px; + border-top: 1px solid var(--border-subtle); +} +#vault-status-slot { flex: 1; min-width: 0; } +.vault-status { + display: flex; + flex-direction: column; + gap: 2px; + font-size: 11px; + line-height: 1.4; +} +.vault-status__state { color: var(--text-dim); } +.vault-status__ts { color: var(--text-muted); } +.vault-status-refresh { + flex: none; + background: none; + border: none; + color: var(--text-dim); + cursor: pointer; + font-family: inherit; + font-size: 13px; + line-height: 1; + padding: 2px 6px; + border-radius: 4px; +} +.vault-status-refresh:hover { color: var(--text); background: var(--bg-input); } +.vault-status-refresh:focus-visible { outline: none; box-shadow: var(--focus-ring); }