From eff030a9d812fecb415df509939b81b0322903a9 Mon Sep 17 00:00:00 2001 From: Matthew Breedlove Date: Mon, 2 Mar 2026 23:21:39 -0500 Subject: [PATCH] Add 'star us on github' menu item, version bump to 2.1.2 --- package.json | 2 +- src/tui/App.tsx | 57 +++++++++++--- src/tui/components/MainMenu.tsx | 13 +++- src/utils/__tests__/open-url.test.ts | 107 +++++++++++++++++++++++++++ src/utils/open-url.ts | 82 ++++++++++++++++++++ 5 files changed, 246 insertions(+), 15 deletions(-) create mode 100644 src/utils/__tests__/open-url.test.ts create mode 100644 src/utils/open-url.ts diff --git a/package.json b/package.json index e9ee2ea..423cdd6 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "ccstatusline", - "version": "2.1.1", + "version": "2.1.2", "description": "A customizable status line formatter for Claude Code CLI", "module": "src/ccstatusline.ts", "type": "module", diff --git a/src/tui/App.tsx b/src/tui/App.tsx index 7cbef15..f70145a 100644 --- a/src/tui/App.tsx +++ b/src/tui/App.tsx @@ -28,6 +28,7 @@ import { loadSettings, saveSettings } from '../utils/config'; +import { openExternalUrl } from '../utils/open-url'; import { checkPowerlineFonts, checkPowerlineFontsAsync, @@ -50,6 +51,13 @@ import { TerminalWidthMenu } from './components'; +const GITHUB_REPO_URL = 'https://github.com/sirmalloc/ccstatusline'; + +interface FlashMessage { + text: string; + color: 'green' | 'red'; +} + export const App: React.FC = () => { const { exit } = useApp(); const [settings, setSettings] = useState(null); @@ -65,7 +73,7 @@ export const App: React.FC = () => { const [installingFonts, setInstallingFonts] = useState(false); const [fontInstallMessage, setFontInstallMessage] = useState(null); const [existingStatusLine, setExistingStatusLine] = useState(null); - const [saveMessage, setSaveMessage] = useState(null); + const [flashMessage, setFlashMessage] = useState(null); const [previewIsTruncated, setPreviewIsTruncated] = useState(false); useEffect(() => { @@ -107,15 +115,15 @@ export const App: React.FC = () => { } }, [settings, originalSettings]); - // Clear save message after 2 seconds + // Clear header message after 2 seconds useEffect(() => { - if (saveMessage) { + if (flashMessage) { const timer = setTimeout(() => { - setSaveMessage(null); + setFlashMessage(null); }, 2000); return () => { clearTimeout(timer); }; } - }, [saveMessage]); + }, [flashMessage]); useInput((input, key) => { if (key.ctrl && input === 'c') { @@ -127,7 +135,10 @@ export const App: React.FC = () => { await saveSettings(settings); setOriginalSettings(JSON.parse(JSON.stringify(settings)) as Settings); setHasChanges(false); - setSaveMessage('✓ Configuration saved'); + setFlashMessage({ + text: '✓ Configuration saved', + color: 'green' + }); })(); } }); @@ -211,6 +222,29 @@ export const App: React.FC = () => { case 'install': handleInstallUninstall(); break; + case 'starGithub': + setConfirmDialog({ + message: `Open the ccstatusline GitHub repository in your browser?\n\n${GITHUB_REPO_URL}`, + action: () => { + const result = openExternalUrl(GITHUB_REPO_URL); + if (result.success) { + setFlashMessage({ + text: '✓ Opened GitHub repository in browser', + color: 'green' + }); + } else { + setFlashMessage({ + text: `✗ Could not open browser. Visit: ${GITHUB_REPO_URL}`, + color: 'red' + }); + } + setScreen('main'); + setConfirmDialog(null); + return Promise.resolve(); + } + }); + setScreen('confirm'); + break; case 'save': await saveSettings(settings); setOriginalSettings(JSON.parse(JSON.stringify(settings)) as Settings); // Update original after save @@ -249,9 +283,9 @@ export const App: React.FC = () => { {` | ${getPackageVersion() && `v${getPackageVersion()}`}`} - {saveMessage && ( - - {` ${saveMessage}`} + {flashMessage && ( + + {` ${flashMessage.text}`} )} @@ -275,7 +309,8 @@ export const App: React.FC = () => { powerline: 2, terminalConfig: 3, globalOverrides: 4, - install: 5 + install: 5, + starGithub: hasChanges ? 8 : 7 }; setMenuSelections({ ...menuSelections, main: menuMap[value] ?? 0 }); } @@ -460,4 +495,4 @@ export function runTUI() { // Clear the terminal before starting the TUI process.stdout.write('\x1b[2J\x1b[H'); render(); -} \ No newline at end of file +} diff --git a/src/tui/components/MainMenu.tsx b/src/tui/components/MainMenu.tsx index 78a8b5b..3173092 100644 --- a/src/tui/components/MainMenu.tsx +++ b/src/tui/components/MainMenu.tsx @@ -36,10 +36,16 @@ export const MainMenu: React.FC = ({ onSelect, isClaudeInstalled, if (hasChanges) { menuItems.push( { label: '💾 Save & Exit', value: 'save', selectable: true }, - { label: '❌ Exit without saving', value: 'exit', selectable: true } + { label: '❌ Exit without saving', value: 'exit', selectable: true }, + { label: '', value: '_gap3', selectable: false }, // Visual gap + { label: '⭐ Like ccstatusline? Star us on GitHub', value: 'starGithub', selectable: true } ); } else { - menuItems.push({ label: '🚪 Exit', value: 'exit', selectable: true }); + menuItems.push( + { label: '🚪 Exit', value: 'exit', selectable: true }, + { label: '', value: '_gap3', selectable: false }, // Visual gap + { label: '⭐ Like ccstatusline? Star us on GitHub', value: 'starGithub', selectable: true } + ); } // Get only selectable items for navigation @@ -69,6 +75,7 @@ export const MainMenu: React.FC = ({ onSelect, isClaudeInstalled, ? 'Remove ccstatusline from your Claude Code settings' : 'Add ccstatusline to your Claude Code settings for automatic status line rendering', terminalConfig: 'Configure terminal-specific settings for optimal display', + starGithub: 'Open the ccstatusline GitHub repository in your browser so you can star the project', save: 'Save all changes and exit the configuration tool', exit: hasChanges ? 'Exit without saving your changes' @@ -117,4 +124,4 @@ export const MainMenu: React.FC = ({ onSelect, isClaudeInstalled, )} ); -}; \ No newline at end of file +}; diff --git a/src/utils/__tests__/open-url.test.ts b/src/utils/__tests__/open-url.test.ts new file mode 100644 index 0000000..a06c5e0 --- /dev/null +++ b/src/utils/__tests__/open-url.test.ts @@ -0,0 +1,107 @@ +import { spawnSync } from 'child_process'; +import * as os from 'os'; +import { + beforeEach, + describe, + expect, + it, + vi +} from 'vitest'; + +import { openExternalUrl } from '../open-url'; + +vi.mock('child_process', () => ({ spawnSync: vi.fn() })); + +const mockSpawnSync = spawnSync as unknown as { + mock: { calls: unknown[][] }; + mockReturnValue: (value: unknown) => void; + mockReturnValueOnce: (value: unknown) => void; +}; + +describe('openExternalUrl', () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.restoreAllMocks(); + }); + + it('uses open on macOS', () => { + vi.spyOn(os, 'platform').mockReturnValue('darwin'); + mockSpawnSync.mockReturnValue({ status: 0 }); + + const result = openExternalUrl('https://github.com/sirmalloc/ccstatusline'); + + expect(result).toEqual({ success: true }); + expect(mockSpawnSync.mock.calls[0]?.[0]).toBe('open'); + expect(mockSpawnSync.mock.calls[0]?.[1]).toEqual(['https://github.com/sirmalloc/ccstatusline']); + }); + + it('uses cmd start on Windows', () => { + vi.spyOn(os, 'platform').mockReturnValue('win32'); + mockSpawnSync.mockReturnValue({ status: 0 }); + + const result = openExternalUrl('https://github.com/sirmalloc/ccstatusline'); + + expect(result).toEqual({ success: true }); + expect(mockSpawnSync.mock.calls[0]?.[0]).toBe('cmd'); + expect(mockSpawnSync.mock.calls[0]?.[1]).toEqual(['/c', 'start', '', 'https://github.com/sirmalloc/ccstatusline']); + }); + + it('uses xdg-open on Linux when available', () => { + vi.spyOn(os, 'platform').mockReturnValue('linux'); + mockSpawnSync.mockReturnValue({ status: 0 }); + + const result = openExternalUrl('https://github.com/sirmalloc/ccstatusline'); + + expect(result).toEqual({ success: true }); + expect(mockSpawnSync.mock.calls[0]?.[0]).toBe('xdg-open'); + expect(mockSpawnSync.mock.calls[0]?.[1]).toEqual(['https://github.com/sirmalloc/ccstatusline']); + }); + + it('falls back to gio open when xdg-open fails', () => { + vi.spyOn(os, 'platform').mockReturnValue('linux'); + mockSpawnSync.mockReturnValueOnce({ status: 1 }); + mockSpawnSync.mockReturnValueOnce({ status: 0 }); + + const result = openExternalUrl('https://github.com/sirmalloc/ccstatusline'); + + expect(result).toEqual({ success: true }); + expect(mockSpawnSync.mock.calls.length).toBe(2); + expect(mockSpawnSync.mock.calls[0]?.[0]).toBe('xdg-open'); + expect(mockSpawnSync.mock.calls[1]?.[0]).toBe('gio'); + expect(mockSpawnSync.mock.calls[1]?.[1]).toEqual(['open', 'https://github.com/sirmalloc/ccstatusline']); + }); + + it('returns failure when Linux openers fail', () => { + vi.spyOn(os, 'platform').mockReturnValue('linux'); + mockSpawnSync.mockReturnValueOnce({ status: 1 }); + mockSpawnSync.mockReturnValueOnce({ status: 2 }); + + const result = openExternalUrl('https://github.com/sirmalloc/ccstatusline'); + + expect(result.success).toBe(false); + expect(result.error).toContain('xdg-open failed'); + expect(result.error).toContain('gio open failed'); + }); + + it('rejects non-http URL protocols', () => { + const result = openExternalUrl('file:///tmp/ccstatusline'); + + expect(result).toEqual({ + success: false, + error: 'Only http(s) URLs are supported' + }); + expect(mockSpawnSync.mock.calls.length).toBe(0); + }); + + it('returns unsupported platform error', () => { + vi.spyOn(os, 'platform').mockReturnValue('freebsd'); + + const result = openExternalUrl('https://github.com/sirmalloc/ccstatusline'); + + expect(result).toEqual({ + success: false, + error: 'Unsupported platform: freebsd' + }); + expect(mockSpawnSync.mock.calls.length).toBe(0); + }); +}); \ No newline at end of file diff --git a/src/utils/open-url.ts b/src/utils/open-url.ts new file mode 100644 index 0000000..54a9234 --- /dev/null +++ b/src/utils/open-url.ts @@ -0,0 +1,82 @@ +import { spawnSync } from 'child_process'; +import * as os from 'os'; + +export interface OpenExternalUrlResult { + success: boolean; + error?: string; +} + +function runOpenCommand(command: string, args: string[]): string | null { + const result = spawnSync(command, args, { + stdio: 'ignore', + windowsHide: true + }); + + if (result.error) { + return result.error.message; + } + + if (result.status !== 0) { + return `Command exited with status ${result.status}`; + } + + if (result.signal) { + return `Command terminated by signal ${result.signal}`; + } + + return null; +} + +export function openExternalUrl(url: string): OpenExternalUrlResult { + let parsedUrl: URL; + + try { + parsedUrl = new URL(url); + } catch { + return { + success: false, + error: 'Invalid URL' + }; + } + + if (parsedUrl.protocol !== 'http:' && parsedUrl.protocol !== 'https:') { + return { + success: false, + error: 'Only http(s) URLs are supported' + }; + } + + const platform = os.platform(); + + if (platform === 'darwin') { + const commandError = runOpenCommand('open', [url]); + return commandError ? { success: false, error: commandError } : { success: true }; + } + + if (platform === 'win32') { + const commandError = runOpenCommand('cmd', ['/c', 'start', '', url]); + return commandError ? { success: false, error: commandError } : { success: true }; + } + + if (platform === 'linux') { + const xdgError = runOpenCommand('xdg-open', [url]); + if (!xdgError) { + return { success: true }; + } + + const gioError = runOpenCommand('gio', ['open', url]); + if (!gioError) { + return { success: true }; + } + + return { + success: false, + error: `xdg-open failed: ${xdgError}; gio open failed: ${gioError}` + }; + } + + return { + success: false, + error: `Unsupported platform: ${platform}` + }; +} \ No newline at end of file