feat: add global keyboard shortcuts for timer toggle and show app

Register CmdOrCtrl+Shift+T (toggle timer) and CmdOrCtrl+Shift+Z
(show app) via tauri-plugin-global-shortcut. Shortcut keys are
configurable in Settings Timer tab. Shortcuts re-register on change.
This commit is contained in:
Your Name
2026-02-18 10:46:18 +02:00
parent 8d0f6c6c7d
commit 5ac890aad4
2 changed files with 205 additions and 1 deletions

View File

@@ -4,9 +4,37 @@ import TitleBar from './components/TitleBar.vue'
import NavRail from './components/NavRail.vue'
import ToastNotification from './components/ToastNotification.vue'
import { useSettingsStore } from './stores/settings'
import { useTimerStore } from './stores/timer'
const settingsStore = useSettingsStore()
async function registerShortcuts() {
try {
const { unregisterAll, register } = await import('@tauri-apps/plugin-global-shortcut')
await unregisterAll()
const toggleKey = settingsStore.settings.shortcut_toggle_timer || 'CmdOrCtrl+Shift+T'
const showKey = settingsStore.settings.shortcut_show_app || 'CmdOrCtrl+Shift+Z'
await register(toggleKey, () => {
const timerStore = useTimerStore()
if (timerStore.isStopped) {
if (timerStore.selectedProjectId) timerStore.start()
} else {
timerStore.stop()
}
})
await register(showKey, async () => {
const { getCurrentWindow } = await import('@tauri-apps/api/window')
const win = getCurrentWindow()
await win.show()
await win.setFocus()
})
} catch (e) {
console.error('Failed to register shortcuts:', e)
}
}
function applyTheme() {
const el = document.documentElement
const mode = settingsStore.settings.theme_mode || 'dark'
@@ -29,6 +57,7 @@ onMounted(async () => {
(app.style as any).zoom = `${zoom}%`
}
applyTheme()
registerShortcuts()
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', () => {
if (settingsStore.settings.theme_mode === 'system') applyTheme()
@@ -38,6 +67,10 @@ onMounted(async () => {
watch(() => [settingsStore.settings.theme_mode, settingsStore.settings.accent_color], () => {
applyTheme()
})
watch(() => [settingsStore.settings.shortcut_toggle_timer, settingsStore.settings.shortcut_show_app], () => {
registerShortcuts()
})
</script>
<template>

View File

@@ -230,6 +230,40 @@
@update:model-value="saveSettings"
/>
</div>
<!-- Divider -->
<div class="border-t border-border-subtle" />
<!-- Keyboard Shortcuts -->
<h3 class="text-[0.6875rem] text-text-tertiary uppercase tracking-[0.08em] font-medium">Keyboard Shortcuts</h3>
<div class="flex items-center justify-between">
<div>
<p class="text-[0.8125rem] text-text-primary">Toggle Timer</p>
<p class="text-[0.6875rem] text-text-tertiary mt-0.5">Start/stop the active timer</p>
</div>
<input
v-model="shortcutToggleTimer"
type="text"
class="w-48 px-3 py-1.5 bg-bg-inset border border-border-subtle rounded-lg text-[0.75rem] text-text-primary font-mono text-center focus:outline-none focus:border-border-visible"
placeholder="CmdOrCtrl+Shift+T"
@change="saveShortcutSettings"
/>
</div>
<div class="flex items-center justify-between">
<div>
<p class="text-[0.8125rem] text-text-primary">Show App</p>
<p class="text-[0.6875rem] text-text-tertiary mt-0.5">Bring the app to front</p>
</div>
<input
v-model="shortcutShowApp"
type="text"
class="w-48 px-3 py-1.5 bg-bg-inset border border-border-subtle rounded-lg text-[0.75rem] text-text-primary font-mono text-center focus:outline-none focus:border-border-visible"
placeholder="CmdOrCtrl+Shift+Z"
@change="saveShortcutSettings"
/>
</div>
</div>
</div>
@@ -272,6 +306,55 @@
</button>
</div>
<!-- Import Data -->
<div class="mb-8">
<h3 class="text-[0.8125rem] font-medium text-text-primary mb-4">Import Data</h3>
<div class="flex items-end gap-3 mb-4">
<div class="w-48">
<label class="block text-[0.6875rem] text-text-tertiary uppercase tracking-[0.08em] mb-1.5">Format</label>
<AppSelect
v-model="importFormat"
:options="importFormats"
label-key="label"
value-key="value"
/>
</div>
<button
@click="handleImportFile"
class="flex items-center gap-2 px-4 py-2 border border-border-visible text-text-primary text-xs rounded-lg hover:bg-bg-elevated transition-colors duration-150"
>
<Upload class="w-3.5 h-3.5" />
Choose File
</button>
</div>
<div v-if="importFileName" class="mb-4">
<p class="text-[0.75rem] text-text-secondary mb-2">File: {{ importFileName }}</p>
<!-- CSV Preview -->
<div v-if="importPreview.length > 0" class="overflow-x-auto mb-3">
<table class="text-[0.6875rem]">
<tr v-for="(row, i) in importPreview" :key="i" :class="i === 0 ? 'text-text-tertiary font-medium' : 'text-text-secondary'">
<td v-for="(cell, j) in row" :key="j" class="pr-4 py-0.5 whitespace-nowrap">{{ cell }}</td>
</tr>
</table>
</div>
<button
@click="executeImport"
:disabled="isImporting"
class="px-4 py-2 bg-accent text-bg-base text-xs font-medium rounded-lg hover:bg-accent-hover transition-colors duration-150 disabled:opacity-40"
>
{{ isImporting ? 'Importing...' : 'Import' }}
</button>
</div>
<p v-if="importStatus" class="text-[0.75rem] mt-2" :class="importStatus.startsWith('Error') ? 'text-status-error' : 'text-status-running'">
{{ importStatus }}
</p>
</div>
<!-- Danger Zone -->
<div class="mt-8 rounded-xl border border-status-error/20 p-5">
<h3 class="text-xs text-status-error uppercase tracking-[0.08em] font-medium mb-4">Danger Zone</h3>
@@ -328,12 +411,13 @@
<script setup lang="ts">
import { ref, onMounted, markRaw } from 'vue'
import { invoke } from '@tauri-apps/api/core'
import { Settings as SettingsIcon, Clock, Receipt, Database, Plus, Minus } from 'lucide-vue-next'
import { Settings as SettingsIcon, Clock, Receipt, Database, Plus, Minus, Upload } from 'lucide-vue-next'
import { useSettingsStore } from '../stores/settings'
import { useToastStore } from '../stores/toast'
import AppNumberInput from '../components/AppNumberInput.vue'
import AppSelect from '../components/AppSelect.vue'
import { LOCALES, getCurrencies, getCurrencySymbol } from '../utils/locale'
import { parseCSV, mapTogglCSV, mapGenericCSV, type ImportEntry } from '../utils/import'
const settingsStore = useSettingsStore()
const toastStore = useToastStore()
@@ -361,6 +445,8 @@ const currency = ref('USD')
const currencyOptions = getCurrencies()
const themeMode = ref('dark')
const accentColor = ref('amber')
const shortcutToggleTimer = ref('CmdOrCtrl+Shift+T')
const shortcutShowApp = ref('CmdOrCtrl+Shift+Z')
const themeModes = [
{ value: 'dark', label: 'Dark' },
@@ -387,6 +473,21 @@ const appTrackingModes = [
// Dialog state
const showClearDataDialog = ref(false)
// Import state
const importFormat = ref('toggl')
const importFileContent = ref('')
const importFileName = ref('')
const importPreview = ref<string[][]>([])
const importStatus = ref('')
const isImporting = ref(false)
const importFormats = [
{ label: 'Toggl CSV', value: 'toggl' },
{ label: 'Clockify CSV', value: 'clockify' },
{ label: 'Generic CSV', value: 'generic' },
{ label: 'ZeroClock JSON', value: 'json' },
]
// Zoom steps
const zoomSteps = [80, 90, 100, 110, 120, 130, 150]
@@ -453,6 +554,74 @@ async function saveThemeSettings() {
await settingsStore.updateSetting('accent_color', accentColor.value)
}
// Save shortcut settings
async function saveShortcutSettings() {
await settingsStore.updateSetting('shortcut_toggle_timer', shortcutToggleTimer.value)
await settingsStore.updateSetting('shortcut_show_app', shortcutShowApp.value)
}
// Import file handling
async function handleImportFile() {
try {
const { open } = await import('@tauri-apps/plugin-dialog')
const selected = await open({
filters: [
{ name: 'Data Files', extensions: ['csv', 'json'] }
]
})
if (!selected) return
const filePath = selected as string
// @ts-ignore - plugin-fs types are available at runtime
const { readTextFile } = await import('@tauri-apps/plugin-fs')
const content: string = await readTextFile(filePath)
importFileContent.value = content
importFileName.value = filePath.split(/[/\\]/).pop() || 'file'
if (importFormat.value === 'json') {
importPreview.value = []
} else {
importPreview.value = parseCSV(content).slice(0, 6)
}
} catch (e) {
console.error('Failed to read file:', e)
}
}
async function executeImport() {
if (!importFileContent.value) return
isImporting.value = true
importStatus.value = 'Importing...'
try {
if (importFormat.value === 'json') {
const data = JSON.parse(importFileContent.value)
await invoke('import_json_data', { data })
importStatus.value = 'Import complete!'
} else {
const rows = parseCSV(importFileContent.value)
let entries: ImportEntry[]
if (importFormat.value === 'toggl' || importFormat.value === 'clockify') {
entries = mapTogglCSV(rows)
} else {
entries = mapGenericCSV(rows, { project: 0, description: 1, start_time: 2, duration: 3, client: -1, task: -1 })
}
await invoke('import_entries', { entries })
importStatus.value = `Imported ${entries.length} entries!`
}
importFileContent.value = ''
importFileName.value = ''
importPreview.value = []
} catch (e) {
importStatus.value = `Error: ${e}`
} finally {
isImporting.value = false
}
}
// Export all data
async function exportData() {
try {
@@ -499,5 +668,7 @@ onMounted(async () => {
currency.value = settingsStore.settings.currency || 'USD'
themeMode.value = settingsStore.settings.theme_mode || 'dark'
accentColor.value = settingsStore.settings.accent_color || 'amber'
shortcutToggleTimer.value = settingsStore.settings.shortcut_toggle_timer || 'CmdOrCtrl+Shift+T'
shortcutShowApp.value = settingsStore.settings.shortcut_show_app || 'CmdOrCtrl+Shift+Z'
})
</script>