chore: change shortcuts datamodel

This commit is contained in:
nchopra 2025-12-21 01:23:07 +05:30
parent 260c028477
commit 10f8f6cb78
8 changed files with 143 additions and 50 deletions

View file

@ -6,8 +6,8 @@ import { api } from "@/trpc/react";
import { toast } from "sonner";
interface ShortcutInputProps {
value?: string;
onChange: (value: string) => void;
value?: string[];
onChange: (value: string[]) => void;
isRecordingShortcut?: boolean;
onRecordingShortcutChange: (recording: boolean) => void;
}
@ -17,7 +17,7 @@ const MAX_KEY_COMBINATION_LENGTH = 4;
type ValidationResult = {
valid: boolean;
shortcut?: string;
shortcut?: string[];
error?: string;
};
@ -45,7 +45,8 @@ function validateShortcut(keys: string[]): ValidationResult {
};
}
return { valid: true, shortcut: [...modifierKeys, ...regularKeys].join("+") };
// Return array format: modifiers first, then regular keys
return { valid: true, shortcut: [...modifierKeys, ...regularKeys] };
}
function RecordingDisplay({
@ -90,17 +91,20 @@ function ShortcutDisplay({
value,
onEdit,
}: {
value?: string;
value?: string[];
onEdit: () => void;
}) {
// Format array as display string (e.g., ["Fn", "Space"] -> "Fn+Space")
const displayValue = value?.length ? value.join("+") : undefined;
return (
<>
{value && (
{displayValue && (
<kbd
onClick={onEdit}
className="inline-flex items-center px-3 py-1 bg-muted hover:bg-muted/70 rounded-md text-sm font-mono cursor-pointer transition-colors"
>
{value}
{displayValue}
</kbd>
)}
<Button

View file

@ -10,6 +10,11 @@
* - To update a single field, fetch the current section, modify it, and save the complete section
* - The SettingsService handles this pattern correctly for all methods
* - Direct calls to updateAppSettings should pass complete sections
*
* Settings Versioning:
* - Settings have a version number for migrations
* - When schema changes, increment CURRENT_SETTINGS_VERSION and add a migration function
* - Migrations run automatically when loading settings with an older version
*/
import { eq } from "drizzle-orm";
@ -21,21 +26,82 @@ import {
} from "./schema";
import { isWindows, isMacOS } from "../utils/platform";
// Current settings schema version - increment when making breaking changes
const CURRENT_SETTINGS_VERSION = 2;
// Type for v1 settings (before shortcuts array migration)
interface AppSettingsDataV1 extends Omit<AppSettingsData, "shortcuts"> {
shortcuts?: {
pushToTalk?: string;
toggleRecording?: string;
toggleWindow?: string;
};
}
// Migration function type
type MigrationFn = (data: unknown) => AppSettingsData;
// Migration functions - keyed by target version
const migrations: Record<number, MigrationFn> = {
// v1 -> v2: Convert shortcuts from string ("Fn+Space") to array (["Fn", "Space"])
2: (data: unknown): AppSettingsData => {
const oldData = data as AppSettingsDataV1;
const oldShortcuts = oldData.shortcuts;
// Convert string shortcuts to arrays
const convertShortcut = (
shortcut: string | undefined,
): string[] | undefined => {
if (!shortcut || shortcut === "") {
return undefined;
}
return shortcut.split("+");
};
return {
...oldData,
shortcuts: oldShortcuts
? {
pushToTalk: convertShortcut(oldShortcuts.pushToTalk),
toggleRecording: convertShortcut(oldShortcuts.toggleRecording),
}
: undefined,
} as AppSettingsData;
},
};
/**
* Run migrations from current version to target version
*/
function migrateSettings(data: unknown, fromVersion: number): AppSettingsData {
let currentData = data;
for (let v = fromVersion + 1; v <= CURRENT_SETTINGS_VERSION; v++) {
const migrationFn = migrations[v];
if (migrationFn) {
currentData = migrationFn(currentData);
console.log(`[Settings] Migrated settings from v${v - 1} to v${v}`);
}
}
return currentData as AppSettingsData;
}
// Singleton ID for app settings (we only have one settings record)
const SETTINGS_ID = 1;
// Platform-specific default shortcuts
// Platform-specific default shortcuts (array format)
const getDefaultShortcuts = () => {
if (isMacOS()) {
return {
pushToTalk: "Fn",
toggleRecording: "Fn+Space",
pushToTalk: ["Fn"],
toggleRecording: ["Fn", "Space"],
};
} else {
// Windows and Linux
return {
pushToTalk: "Ctrl+Win",
toggleRecording: "Ctrl+Win+Space",
pushToTalk: ["Ctrl", "Win"],
toggleRecording: ["Ctrl", "Win", "Space"],
};
}
};
@ -75,7 +141,7 @@ const defaultSettings: AppSettingsData = {
},
};
// Get all app settings
// Get all app settings (with automatic migration if needed)
export async function getAppSettings(): Promise<AppSettingsData> {
const result = await db
.select()
@ -88,7 +154,30 @@ export async function getAppSettings(): Promise<AppSettingsData> {
return defaultSettings;
}
return result[0].data;
const record = result[0];
// Check if migration is needed
if (record.version < CURRENT_SETTINGS_VERSION) {
const migratedData = migrateSettings(record.data, record.version);
// Save migrated data with new version
const now = new Date();
await db
.update(appSettings)
.set({
data: migratedData,
version: CURRENT_SETTINGS_VERSION,
updatedAt: now,
})
.where(eq(appSettings.id, SETTINGS_ID));
console.log(
`[Settings] Migration complete: v${record.version} -> v${CURRENT_SETTINGS_VERSION}`,
);
return migratedData;
}
return record.data;
}
// Update app settings (shallow merge at top level only)
@ -167,7 +256,7 @@ async function createDefaultSettings(): Promise<void> {
const newSettings: NewAppSettings = {
id: SETTINGS_ID,
data: defaultSettings,
version: 1,
version: CURRENT_SETTINGS_VERSION,
createdAt: now,
updatedAt: now,
};

View file

@ -132,9 +132,8 @@ export interface AppSettingsData {
preferredMicrophoneName?: string;
};
shortcuts?: {
pushToTalk?: string;
toggleRecording?: string;
toggleWindow?: string;
pushToTalk?: string[];
toggleRecording?: string[];
};
modelProvidersConfig?: {

View file

@ -15,15 +15,15 @@ interface KeyInfo {
}
interface ShortcutConfig {
pushToTalk: string;
toggleRecording: string;
pushToTalk: string[];
toggleRecording: string[];
}
export class ShortcutManager extends EventEmitter {
private activeKeys = new Map<string, KeyInfo>();
private shortcuts: ShortcutConfig = {
pushToTalk: "",
toggleRecording: "",
pushToTalk: [],
toggleRecording: [],
};
private settingsService: SettingsService;
private nativeBridge: NativeBridge | null = null;
@ -162,26 +162,26 @@ export class ShortcutManager extends EventEmitter {
}
private isPTTShortcutPressed(): boolean {
if (!this.shortcuts.pushToTalk) {
const pttKeys = this.shortcuts.pushToTalk;
if (!pttKeys || pttKeys.length === 0) {
return false;
}
const pttKeys = this.shortcuts.pushToTalk.split("+");
const activeKeysList = this.getActiveKeys();
//! This should only be a subset match
// PTT: subset match - all PTT keys must be pressed (can have extra keys)
return pttKeys.every((key) => activeKeysList.includes(key));
}
private isToggleRecordingShortcutPressed(): boolean {
if (!this.shortcuts.toggleRecording) {
const toggleKeys = this.shortcuts.toggleRecording;
if (!toggleKeys || toggleKeys.length === 0) {
return false;
}
const toggleKeys = this.shortcuts.toggleRecording.split("+");
const activeKeysList = this.getActiveKeys();
// Check if toggle recording keys match active keys exactly
// Toggle: exact match - only these keys pressed, no extra keys
return (
toggleKeys.length === activeKeysList.length &&
toggleKeys.every((key) => activeKeysList.includes(key))

View file

@ -7,8 +7,10 @@ import { api } from "@/trpc/react";
import { toast } from "sonner";
export function ShortcutsSettingsPage() {
const [pushToTalkShortcut, setPushToTalkShortcut] = useState("");
const [toggleRecordingShortcut, setToggleRecordingShortcut] = useState("");
const [pushToTalkShortcut, setPushToTalkShortcut] = useState<string[]>([]);
const [toggleRecordingShortcut, setToggleRecordingShortcut] = useState<
string[]
>([]);
const [recordingShortcut, setRecordingShortcut] = useState<
"pushToTalk" | "toggleRecording" | null
>(null);
@ -40,7 +42,7 @@ export function ShortcutsSettingsPage() {
}
}, [shortcutsQuery.data]);
const handlePushToTalkChange = (shortcut: string) => {
const handlePushToTalkChange = (shortcut: string[]) => {
setPushToTalkShortcut(shortcut);
setShortcutMutation.mutate({
type: "pushToTalk",
@ -48,7 +50,7 @@ export function ShortcutsSettingsPage() {
});
};
const handleToggleRecordingChange = (shortcut: string) => {
const handleToggleRecordingChange = (shortcut: string[]) => {
setToggleRecordingShortcut(shortcut);
setShortcutMutation.mutate({
type: "toggleRecording",

View file

@ -8,7 +8,7 @@ import { api } from "@/trpc/react";
* Wraps ShortcutInput with label and handles data fetching/saving
*/
export function OnboardingShortcutInput() {
const [pushToTalkShortcut, setPushToTalkShortcut] = useState("");
const [pushToTalkShortcut, setPushToTalkShortcut] = useState<string[]>([]);
const [isRecording, setIsRecording] = useState(false);
const utils = api.useUtils();
@ -26,7 +26,7 @@ export function OnboardingShortcutInput() {
}
}, [shortcutsQuery.data]);
const handleShortcutChange = (shortcut: string) => {
const handleShortcutChange = (shortcut: string[]) => {
setPushToTalkShortcut(shortcut);
setShortcutMutation.mutate({
type: "pushToTalk",

View file

@ -8,14 +8,13 @@ import {
updateAppSettings,
} from "../db/app-settings";
import type { AppSettingsData } from "../db/schema";
import { isMacOS } from "../utils/platform";
/**
* Database-backed settings service with typed configuration
*/
export interface ShortcutsConfig {
pushToTalk: string;
toggleRecording: string;
pushToTalk: string[];
toggleRecording: string[];
}
export interface AppPreferences {
@ -133,18 +132,14 @@ export class SettingsService extends EventEmitter {
}
/**
* Get shortcuts configuration with defaults
* Get shortcuts configuration
* Defaults are handled by app-settings.ts during initialization/migration
*/
async getShortcuts(): Promise<ShortcutsConfig> {
const shortcuts = await getSettingsSection("shortcuts");
// Return platform-specific defaults if not set
const defaults = isMacOS()
? { pushToTalk: "Fn", toggleRecording: "Fn+Space" }
: { pushToTalk: "Ctrl+Win", toggleRecording: "Ctrl+Win+Space" };
return {
pushToTalk: shortcuts?.pushToTalk || defaults.pushToTalk,
toggleRecording: shortcuts?.toggleRecording || defaults.toggleRecording,
pushToTalk: shortcuts?.pushToTalk ?? [],
toggleRecording: shortcuts?.toggleRecording ?? [],
};
}
@ -152,10 +147,14 @@ export class SettingsService extends EventEmitter {
* Update shortcuts configuration
*/
async setShortcuts(shortcuts: ShortcutsConfig): Promise<void> {
// Store empty strings as undefined to clear shortcuts
// Store empty arrays as undefined to clear shortcuts
const dataToStore = {
pushToTalk: shortcuts.pushToTalk || undefined,
toggleRecording: shortcuts.toggleRecording || undefined,
pushToTalk: shortcuts.pushToTalk?.length
? shortcuts.pushToTalk
: undefined,
toggleRecording: shortcuts.toggleRecording?.length
? shortcuts.toggleRecording
: undefined,
};
await updateSettingsSection("shortcuts", dataToStore);
}

View file

@ -11,10 +11,10 @@ const FormatterConfigSchema = z.object({
enabled: z.boolean(),
});
// Shortcut schema
// Shortcut schema (array of key names)
const SetShortcutSchema = z.object({
type: z.enum(["pushToTalk", "toggleRecording"]),
shortcut: z.string(),
shortcut: z.array(z.string()),
});
// Model providers schemas