refactor(store): Extract utility functions into store/utils/

Move pure utility functions from app-store.ts and type files into
dedicated utils modules for better separation of concerns:

- theme-utils.ts: Theme and font storage utilities
- shortcut-utils.ts: Keyboard shortcut parsing/formatting
- usage-utils.ts: Usage limit checking

All utilities are re-exported from store/utils/index.ts and
app-store.ts maintains backward compatibility for existing imports.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Shirone
2026-01-25 22:52:58 +01:00
parent 7fe9aacb09
commit 8caec15199
8 changed files with 318 additions and 270 deletions

View File

@@ -0,0 +1,13 @@
// Theme utilities (PUBLIC)
export {
THEME_STORAGE_KEY,
getStoredTheme,
getStoredFontSans,
getStoredFontMono,
} from './theme-utils';
// Shortcut utilities (PUBLIC)
export { parseShortcut, formatShortcut, DEFAULT_KEYBOARD_SHORTCUTS } from './shortcut-utils';
// Usage utilities (PUBLIC)
export { isClaudeUsageAtLimit } from './usage-utils';

View File

@@ -0,0 +1,117 @@
import type { ShortcutKey, KeyboardShortcuts } from '../types/ui-types';
// Helper to parse shortcut string to ShortcutKey object
export function parseShortcut(shortcut: string | undefined | null): ShortcutKey {
if (!shortcut) return { key: '' };
const parts = shortcut.split('+').map((p) => p.trim());
const result: ShortcutKey = { key: parts[parts.length - 1] };
// Normalize common OS-specific modifiers (Cmd/Ctrl/Win/Super symbols) into cmdCtrl
for (let i = 0; i < parts.length - 1; i++) {
const modifier = parts[i].toLowerCase();
if (modifier === 'shift') result.shift = true;
else if (
modifier === 'cmd' ||
modifier === 'ctrl' ||
modifier === 'win' ||
modifier === 'super' ||
modifier === '⌘' ||
modifier === '^' ||
modifier === '⊞' ||
modifier === '◆'
)
result.cmdCtrl = true;
else if (modifier === 'alt' || modifier === 'opt' || modifier === 'option' || modifier === '⌥')
result.alt = true;
}
return result;
}
// Helper to format ShortcutKey to display string
export function formatShortcut(shortcut: string | undefined | null, forDisplay = false): string {
if (!shortcut) return '';
const parsed = parseShortcut(shortcut);
const parts: string[] = [];
// Prefer User-Agent Client Hints when available; fall back to legacy
const platform: 'darwin' | 'win32' | 'linux' = (() => {
if (typeof navigator === 'undefined') return 'linux';
const uaPlatform = (
navigator as Navigator & { userAgentData?: { platform?: string } }
).userAgentData?.platform?.toLowerCase?.();
const legacyPlatform = navigator.platform?.toLowerCase?.();
const platformString = uaPlatform || legacyPlatform || '';
if (platformString.includes('mac')) return 'darwin';
if (platformString.includes('win')) return 'win32';
return 'linux';
})();
// Primary modifier - OS-specific
if (parsed.cmdCtrl) {
if (forDisplay) {
parts.push(platform === 'darwin' ? '⌘' : platform === 'win32' ? '⊞' : '◆');
} else {
parts.push(platform === 'darwin' ? 'Cmd' : platform === 'win32' ? 'Win' : 'Super');
}
}
// Alt/Option
if (parsed.alt) {
parts.push(
forDisplay ? (platform === 'darwin' ? '⌥' : 'Alt') : platform === 'darwin' ? 'Opt' : 'Alt'
);
}
// Shift
if (parsed.shift) {
parts.push(forDisplay ? '⇧' : 'Shift');
}
parts.push(parsed.key.toUpperCase());
// Add spacing when displaying symbols
return parts.join(forDisplay ? ' ' : '+');
}
// Default keyboard shortcuts
export const DEFAULT_KEYBOARD_SHORTCUTS: KeyboardShortcuts = {
// Navigation
board: 'K',
graph: 'H',
agent: 'A',
spec: 'D',
context: 'C',
memory: 'Y',
settings: 'S',
projectSettings: 'Shift+S',
terminal: 'T',
ideation: 'I',
notifications: 'X',
githubIssues: 'G',
githubPrs: 'R',
// UI
toggleSidebar: '`',
// Actions
// Note: Some shortcuts share the same key (e.g., "N" for addFeature, newSession)
// This is intentional as they are context-specific and only active in their respective views
addFeature: 'N', // Only active in board view
addContextFile: 'N', // Only active in context view
startNext: 'G', // Only active in board view
newSession: 'N', // Only active in agent view
openProject: 'O', // Global shortcut
projectPicker: 'P', // Global shortcut
cyclePrevProject: 'Q', // Global shortcut
cycleNextProject: 'E', // Global shortcut
// Terminal shortcuts (only active in terminal view)
// Using Alt modifier to avoid conflicts with both terminal signals AND browser shortcuts
splitTerminalRight: 'Alt+D',
splitTerminalDown: 'Alt+S',
closeTerminal: 'Alt+W',
newTerminalTab: 'Alt+T',
};

View File

@@ -0,0 +1,117 @@
import { getItem, setItem, removeItem } from '@/lib/storage';
import { DEFAULT_FONT_VALUE } from '@/config/ui-font-options';
import type { Project } from '@/lib/electron';
import type { ThemeMode } from '../types/ui-types';
// LocalStorage keys for persistence (fallback when server settings aren't available)
export const THEME_STORAGE_KEY = 'automaker:theme';
const FONT_SANS_STORAGE_KEY = 'automaker:font-sans';
const FONT_MONO_STORAGE_KEY = 'automaker:font-mono';
/**
* Get the theme from localStorage as a fallback
* Used before server settings are loaded (e.g., on login/setup pages)
*/
export function getStoredTheme(): ThemeMode | null {
const stored = getItem(THEME_STORAGE_KEY);
if (stored) return stored as ThemeMode;
// Backwards compatibility: older versions stored theme inside the Zustand persist blob.
// We intentionally keep reading it as a fallback so users don't get a "default theme flash"
// on login/logged-out pages if THEME_STORAGE_KEY hasn't been written yet.
try {
const legacy = getItem('automaker-storage');
if (!legacy) return null;
interface LegacyStorageFormat {
state?: { theme?: string };
theme?: string;
}
const parsed = JSON.parse(legacy) as LegacyStorageFormat;
const theme = parsed.state?.theme ?? parsed.theme;
if (typeof theme === 'string' && theme.length > 0) {
return theme as ThemeMode;
}
} catch {
// Ignore legacy parse errors
}
return null;
}
/**
* Helper to get effective font value with validation
* Returns the font to use (project override -> global -> null for default)
* @param projectFont - The project-specific font override
* @param globalFont - The global font setting
* @param fontOptions - The list of valid font options for validation
*/
export function getEffectiveFont(
projectFont: string | undefined,
globalFont: string | null,
fontOptions: readonly { value: string; label: string }[]
): string | null {
const isValidFont = (font: string | null | undefined): boolean => {
if (!font || font === DEFAULT_FONT_VALUE) return true;
return fontOptions.some((opt) => opt.value === font);
};
if (projectFont) {
if (isValidFont(projectFont)) {
return projectFont === DEFAULT_FONT_VALUE ? null : projectFont;
}
// Invalid project font -> fall through to check global font
}
if (!isValidFont(globalFont)) return null; // Fallback to default if font not in list
return globalFont === DEFAULT_FONT_VALUE ? null : globalFont;
}
/**
* Save theme to localStorage for immediate persistence
* This is used as a fallback when server settings can't be loaded
*/
export function saveThemeToStorage(theme: ThemeMode): void {
setItem(THEME_STORAGE_KEY, theme);
}
/**
* Get fonts from localStorage as a fallback
* Used before server settings are loaded (e.g., on login/setup pages)
*/
export function getStoredFontSans(): string | null {
return getItem(FONT_SANS_STORAGE_KEY);
}
export function getStoredFontMono(): string | null {
return getItem(FONT_MONO_STORAGE_KEY);
}
/**
* Save fonts to localStorage for immediate persistence
* This is used as a fallback when server settings can't be loaded
*/
export function saveFontSansToStorage(fontFamily: string | null): void {
if (fontFamily) {
setItem(FONT_SANS_STORAGE_KEY, fontFamily);
} else {
// Remove from storage if null (using default)
removeItem(FONT_SANS_STORAGE_KEY);
}
}
export function saveFontMonoToStorage(fontFamily: string | null): void {
if (fontFamily) {
setItem(FONT_MONO_STORAGE_KEY, fontFamily);
} else {
// Remove from storage if null (using default)
removeItem(FONT_MONO_STORAGE_KEY);
}
}
export function persistEffectiveThemeForProject(
project: Project | null,
fallbackTheme: ThemeMode
): void {
const projectTheme = project?.theme as ThemeMode | undefined;
const themeToStore = projectTheme ?? fallbackTheme;
saveThemeToStorage(themeToStore);
}

View File

@@ -0,0 +1,34 @@
import type { ClaudeUsage } from '../types/usage-types';
/**
* Check if Claude usage is at its limit (any of: session >= 100%, weekly >= 100%, OR cost >= limit)
* Returns true if any limit is reached, meaning auto mode should pause feature pickup.
*/
export function isClaudeUsageAtLimit(claudeUsage: ClaudeUsage | null): boolean {
if (!claudeUsage) {
// No usage data available - don't block
return false;
}
// Check session limit (5-hour window)
if (claudeUsage.sessionPercentage >= 100) {
return true;
}
// Check weekly limit
if (claudeUsage.weeklyPercentage >= 100) {
return true;
}
// Check cost limit (if configured)
if (
claudeUsage.costLimit !== null &&
claudeUsage.costLimit > 0 &&
claudeUsage.costUsed !== null &&
claudeUsage.costUsed >= claudeUsage.costLimit
) {
return true;
}
return false;
}