chore: change shortcuts datamodel
This commit is contained in:
parent
260c028477
commit
10f8f6cb78
8 changed files with 143 additions and 50 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -132,9 +132,8 @@ export interface AppSettingsData {
|
|||
preferredMicrophoneName?: string;
|
||||
};
|
||||
shortcuts?: {
|
||||
pushToTalk?: string;
|
||||
toggleRecording?: string;
|
||||
toggleWindow?: string;
|
||||
pushToTalk?: string[];
|
||||
toggleRecording?: string[];
|
||||
};
|
||||
|
||||
modelProvidersConfig?: {
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue