From 61dbb4d3a3102f54848de21c82af3aff92f7eb30 Mon Sep 17 00:00:00 2001 From: adlee-was-taken Date: Fri, 1 May 2026 18:06:15 -0400 Subject: [PATCH] ext(affordances): wireHostnameChip with debounced URL parse --- extension/src/popup/styles.css | 23 ++++++++ .../__tests__/url-tools.test.ts | 59 ++++++++++++++++++- .../src/shared/form-affordances/url-tools.ts | 55 +++++++++++++++++ extension/src/vault/vault.css | 23 ++++++++ 4 files changed, 159 insertions(+), 1 deletion(-) diff --git a/extension/src/popup/styles.css b/extension/src/popup/styles.css index 6a35990..5e5dcb6 100644 --- a/extension/src/popup/styles.css +++ b/extension/src/popup/styles.css @@ -1362,3 +1362,26 @@ textarea { opacity: 0.4; cursor: not-allowed; } + +.hostname-chip-row { + display: flex; + align-items: center; + gap: 6px; + margin-top: 4px; + font-size: 11px; + color: var(--text-muted); +} +.hostname-chip { + display: inline-flex; + align-items: center; + justify-content: center; + width: 18px; + height: 18px; + border-radius: 3px; + font-size: 10px; + font-weight: 600; + color: #0c1118; +} +.hostname-text { + font-family: ui-monospace, monospace; +} diff --git a/extension/src/shared/form-affordances/__tests__/url-tools.test.ts b/extension/src/shared/form-affordances/__tests__/url-tools.test.ts index 7794f84..0373ca8 100644 --- a/extension/src/shared/form-affordances/__tests__/url-tools.test.ts +++ b/extension/src/shared/form-affordances/__tests__/url-tools.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; -import { wireFillFromTab } from '../url-tools'; +import { wireFillFromTab, wireHostnameChip } from '../url-tools'; describe('wireFillFromTab', () => { let form: HTMLElement; @@ -44,3 +44,60 @@ describe('wireFillFromTab', () => { expect((form.querySelector('#fill-from-tab-btn') as HTMLButtonElement).disabled).toBe(true); }); }); + +describe('wireHostnameChip', () => { + let form: HTMLElement; + + beforeEach(() => { + form = document.createElement('div'); + form.innerHTML = ` +
+ + +
+ `; + document.body.appendChild(form); + vi.useFakeTimers(); + }); + + it('renders chip + hostname on valid URL after debounce', () => { + wireHostnameChip(form); + const input = form.querySelector('#f-url') as HTMLInputElement; + input.value = 'https://github.com/login'; + input.dispatchEvent(new Event('input')); + vi.advanceTimersByTime(250); + const row = form.querySelector('#hostname-chip-row') as HTMLElement; + expect(row.hidden).toBe(false); + expect(row.textContent).toContain('github.com'); + expect(row.querySelector('.hostname-chip')?.textContent).toBe('G'); + }); + + it('hides chip if URL is empty', () => { + wireHostnameChip(form); + const input = form.querySelector('#f-url') as HTMLInputElement; + input.value = ''; + input.dispatchEvent(new Event('input')); + vi.advanceTimersByTime(250); + expect((form.querySelector('#hostname-chip-row') as HTMLElement).hidden).toBe(true); + }); + + it('hides chip if URL does not parse', () => { + wireHostnameChip(form); + const input = form.querySelector('#f-url') as HTMLInputElement; + input.value = '!!!not-a-url'; + input.dispatchEvent(new Event('input')); + vi.advanceTimersByTime(250); + expect((form.querySelector('#hostname-chip-row') as HTMLElement).hidden).toBe(true); + }); + + it('treats scheme-less host as https://', () => { + wireHostnameChip(form); + const input = form.querySelector('#f-url') as HTMLInputElement; + input.value = 'gitlab.com/users/sign_in'; + input.dispatchEvent(new Event('input')); + vi.advanceTimersByTime(250); + const row = form.querySelector('#hostname-chip-row') as HTMLElement; + expect(row.hidden).toBe(false); + expect(row.textContent).toContain('gitlab.com'); + }); +}); diff --git a/extension/src/shared/form-affordances/url-tools.ts b/extension/src/shared/form-affordances/url-tools.ts index 5f71646..5b6b0ae 100644 --- a/extension/src/shared/form-affordances/url-tools.ts +++ b/extension/src/shared/form-affordances/url-tools.ts @@ -22,3 +22,58 @@ export function wireFillFromTab(form: HTMLElement, opts: FillFromTabOpts): void } export const FILL_FROM_TAB_BTN_HTML = ``; + +const CHIP_HUES = [ + '#5ea0c4', '#c47e5e', '#5ec47a', '#c45e9c', + '#a3c45e', '#7e5ec4', '#c4b75e', '#5ec4c4', +]; + +function hostnameHue(host: string): string { + let h = 0; + for (let i = 0; i < host.length; i++) h = (h * 31 + host.charCodeAt(i)) | 0; + return CHIP_HUES[Math.abs(h) % CHIP_HUES.length]; +} + +function tryParseHost(raw: string): string | null { + const trimmed = raw.trim(); + if (!trimmed) return null; + const candidate = /^[a-zA-Z][a-zA-Z0-9+.-]*:\/\//.test(trimmed) ? trimmed : `https://${trimmed}`; + try { + const u = new URL(candidate); + const host = u.host || null; + if (!host) return null; + // Validate hostname contains only valid characters + if (!/^[a-zA-Z0-9]([a-zA-Z0-9-]*[a-zA-Z0-9])?(\.[a-zA-Z0-9]([a-zA-Z0-9-]*[a-zA-Z0-9])?)*$/.test(host)) { + return null; + } + return host; + } catch { + return null; + } +} + +export function wireHostnameChip(form: HTMLElement): void { + const input = form.querySelector('#f-url'); + const row = form.querySelector('#hostname-chip-row'); + if (!input || !row) return; + let timer: ReturnType | null = null; + + const update = () => { + const host = tryParseHost(input.value); + if (!host) { + row.hidden = true; + row.innerHTML = ''; + return; + } + const initial = host[0]?.toUpperCase() ?? '?'; + const hue = hostnameHue(host); + row.hidden = false; + row.innerHTML = `${initial}${host}`; + }; + + input.addEventListener('input', () => { + if (timer !== null) clearTimeout(timer); + timer = setTimeout(() => { timer = null; update(); }, 200); + }); + update(); // initial render for prefilled values +} diff --git a/extension/src/vault/vault.css b/extension/src/vault/vault.css index 02763eb..bb808e8 100644 --- a/extension/src/vault/vault.css +++ b/extension/src/vault/vault.css @@ -1392,3 +1392,26 @@ textarea { opacity: 0.4; cursor: not-allowed; } + +.hostname-chip-row { + display: flex; + align-items: center; + gap: 6px; + margin-top: 4px; + font-size: 11px; + color: var(--text-muted); +} +.hostname-chip { + display: inline-flex; + align-items: center; + justify-content: center; + width: 18px; + height: 18px; + border-radius: 3px; + font-size: 10px; + font-weight: 600; + color: #0c1118; +} +.hostname-text { + font-family: ui-monospace, monospace; +}