@hienlh/ppm 0.6.2 → 0.6.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) hide show
  1. package/CHANGELOG.md +10 -0
  2. package/dist/web/assets/api-client-D0pZeYY8.js +1 -0
  3. package/dist/web/assets/{chat-tab-BdiG3Gnr.js → chat-tab-DjE_8Csw.js} +5 -5
  4. package/dist/web/assets/code-editor-witrClmz.js +1 -0
  5. package/dist/web/assets/diff-viewer-DSU--yFW.js +4 -0
  6. package/dist/web/assets/{dist-CJbcT4CK.js → dist-PpKqMvyx.js} +2 -2
  7. package/dist/web/assets/git-graph-HpcOYt3G.js +1 -0
  8. package/dist/web/assets/index-CcXQ5iQw.js +21 -0
  9. package/dist/web/assets/index-DyEgsogR.css +2 -0
  10. package/dist/web/assets/input-CCCPR1s4.js +41 -0
  11. package/dist/web/assets/jsx-runtime-wQxeESYQ.js +1 -0
  12. package/dist/web/assets/keybindings-store-C_KQKrsc.js +1 -0
  13. package/dist/web/assets/{markdown-renderer-BPKEwysz.js → markdown-renderer-DSw-4oxk.js} +1 -1
  14. package/dist/web/assets/postgres-viewer-BnkGPi0L.js +1 -0
  15. package/dist/web/assets/{jsx-runtime-B4BJKQ1u.js → react-CYzKIDNi.js} +1 -1
  16. package/dist/web/assets/react-l9v2XLcs.js +1 -0
  17. package/dist/web/assets/{settings-store-BRFvbsHd.js → settings-store-B5g1Gis-.js} +1 -1
  18. package/dist/web/assets/settings-tab-DpQdg9OW.js +1 -0
  19. package/dist/web/assets/sqlite-viewer-JZvegGV-.js +1 -0
  20. package/dist/web/assets/{tab-store-Bf9z6T8D.js → tab-store-DhXold0e.js} +1 -1
  21. package/dist/web/assets/{terminal-tab-Dt9bjwC8.js → terminal-tab-CAQvs2wj.js} +1 -1
  22. package/dist/web/assets/{use-monaco-theme-yxUtuNlu.js → use-monaco-theme-GX0lrqac.js} +1 -1
  23. package/dist/web/index.html +9 -8
  24. package/dist/web/sw.js +1 -1
  25. package/package.json +1 -1
  26. package/src/cli/commands/db-cmd.ts +338 -0
  27. package/src/server/routes/settings.ts +33 -0
  28. package/src/services/db.service.ts +99 -1
  29. package/src/web/app.tsx +7 -2
  30. package/src/web/components/settings/keyboard-shortcuts-section.tsx +182 -0
  31. package/src/web/components/settings/settings-tab.tsx +5 -0
  32. package/src/web/hooks/use-global-keybindings.ts +74 -14
  33. package/src/web/stores/keybindings-store.ts +192 -0
  34. package/dist/web/assets/api-client-DPWUomlf.js +0 -1
  35. package/dist/web/assets/code-editor-soN1frMc.js +0 -1
  36. package/dist/web/assets/diff-viewer-DJEB1zOd.js +0 -4
  37. package/dist/web/assets/git-graph-CrU7vGxw.js +0 -1
  38. package/dist/web/assets/index-CmrE0Xoy.js +0 -21
  39. package/dist/web/assets/index-g11aaU-x.css +0 -2
  40. package/dist/web/assets/input-DMu1FA4M.js +0 -41
  41. package/dist/web/assets/postgres-viewer-lBV4F44Q.js +0 -1
  42. package/dist/web/assets/react-Bo97Lrzq.js +0 -1
  43. package/dist/web/assets/rotate-ccw-Dx0ShAKj.js +0 -1
  44. package/dist/web/assets/settings-tab-Div5NL2d.js +0 -1
  45. package/dist/web/assets/sqlite-viewer-BbgWU-v3.js +0 -1
@@ -4,7 +4,7 @@ import { homedir } from "node:os";
4
4
  import { mkdirSync, existsSync } from "node:fs";
5
5
 
6
6
  const PPM_DIR = resolve(homedir(), ".ppm");
7
- const CURRENT_SCHEMA_VERSION = 1;
7
+ const CURRENT_SCHEMA_VERSION = 2;
8
8
 
9
9
  let db: Database | null = null;
10
10
  let dbProfile: string | null = null;
@@ -121,6 +121,27 @@ function runMigrations(database: Database): void {
121
121
  PRAGMA user_version = 1;
122
122
  `);
123
123
  }
124
+
125
+ if (current < 2) {
126
+ database.exec(`
127
+ CREATE TABLE IF NOT EXISTS connections (
128
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
129
+ type TEXT NOT NULL CHECK(type IN ('sqlite', 'postgres')),
130
+ name TEXT NOT NULL UNIQUE,
131
+ connection_config TEXT NOT NULL,
132
+ group_name TEXT,
133
+ color TEXT,
134
+ sort_order INTEGER NOT NULL DEFAULT 0,
135
+ created_at TEXT DEFAULT (datetime('now')),
136
+ updated_at TEXT DEFAULT (datetime('now'))
137
+ );
138
+
139
+ CREATE INDEX IF NOT EXISTS idx_connections_type ON connections(type);
140
+ CREATE INDEX IF NOT EXISTS idx_connections_group ON connections(group_name);
141
+
142
+ PRAGMA user_version = 2;
143
+ `);
144
+ }
124
145
  }
125
146
 
126
147
  // ---------------------------------------------------------------------------
@@ -299,5 +320,82 @@ export function getDbFilePath(): string {
299
320
  return getDbPath();
300
321
  }
301
322
 
323
+ // ---------------------------------------------------------------------------
324
+ // Connection helpers
325
+ // ---------------------------------------------------------------------------
326
+
327
+ export interface ConnectionRow {
328
+ id: number;
329
+ type: "sqlite" | "postgres";
330
+ name: string;
331
+ connection_config: string;
332
+ group_name: string | null;
333
+ color: string | null;
334
+ sort_order: number;
335
+ created_at: string;
336
+ updated_at: string;
337
+ }
338
+
339
+ /** Parsed config stored in connection_config JSON */
340
+ export type ConnectionConfig =
341
+ | { type: "sqlite"; path: string }
342
+ | { type: "postgres"; connectionString: string };
343
+
344
+ export function getConnections(): ConnectionRow[] {
345
+ return getDb().query(
346
+ "SELECT * FROM connections ORDER BY sort_order, id",
347
+ ).all() as ConnectionRow[];
348
+ }
349
+
350
+ export function getConnectionById(id: number): ConnectionRow | null {
351
+ return getDb().query("SELECT * FROM connections WHERE id = ?").get(id) as ConnectionRow | null;
352
+ }
353
+
354
+ export function getConnectionByName(name: string): ConnectionRow | null {
355
+ return getDb().query("SELECT * FROM connections WHERE name = ?").get(name) as ConnectionRow | null;
356
+ }
357
+
358
+ /** Resolve a connection by name or numeric ID */
359
+ export function resolveConnection(nameOrId: string): ConnectionRow | null {
360
+ const asNum = Number(nameOrId);
361
+ if (!Number.isNaN(asNum) && Number.isInteger(asNum)) {
362
+ return getConnectionById(asNum) ?? getConnectionByName(nameOrId);
363
+ }
364
+ return getConnectionByName(nameOrId);
365
+ }
366
+
367
+ export function insertConnection(
368
+ type: "sqlite" | "postgres", name: string, config: ConnectionConfig,
369
+ groupName?: string | null, color?: string | null,
370
+ ): ConnectionRow {
371
+ const maxOrder = (getDb().query("SELECT COALESCE(MAX(sort_order), -1) as m FROM connections").get() as { m: number }).m;
372
+ getDb().query(
373
+ "INSERT INTO connections (type, name, connection_config, group_name, color, sort_order) VALUES (?, ?, ?, ?, ?, ?)",
374
+ ).run(type, name, JSON.stringify(config), groupName ?? null, color ?? null, maxOrder + 1);
375
+ return getConnectionByName(name)!;
376
+ }
377
+
378
+ export function deleteConnection(nameOrId: string): boolean {
379
+ const conn = resolveConnection(nameOrId);
380
+ if (!conn) return false;
381
+ getDb().query("DELETE FROM connections WHERE id = ?").run(conn.id);
382
+ return true;
383
+ }
384
+
385
+ export function updateConnection(
386
+ id: number, updates: { name?: string; config?: ConnectionConfig; groupName?: string | null; color?: string | null },
387
+ ): void {
388
+ const sets: string[] = [];
389
+ const vals: unknown[] = [];
390
+ if (updates.name !== undefined) { sets.push("name = ?"); vals.push(updates.name); }
391
+ if (updates.config !== undefined) { sets.push("connection_config = ?"); vals.push(JSON.stringify(updates.config)); }
392
+ if (updates.groupName !== undefined) { sets.push("group_name = ?"); vals.push(updates.groupName); }
393
+ if (updates.color !== undefined) { sets.push("color = ?"); vals.push(updates.color); }
394
+ if (sets.length === 0) return;
395
+ sets.push("updated_at = datetime('now')");
396
+ vals.push(id);
397
+ getDb().query(`UPDATE connections SET ${sets.join(", ")} WHERE id = ?`).run(...vals);
398
+ }
399
+
302
400
  // Auto-close on process exit
303
401
  process.on("beforeExit", closeDb);
package/src/web/app.tsx CHANGED
@@ -58,8 +58,13 @@ export function App() {
58
58
  }
59
59
  }, [theme]);
60
60
 
61
- // Fetch server info on mount (before auth — shown on login screen)
62
- useEffect(() => { fetchServerInfo(); }, [fetchServerInfo]);
61
+ // Fetch server info + keybindings on mount (before auth — shown on login screen)
62
+ useEffect(() => {
63
+ fetchServerInfo();
64
+ import("@/stores/keybindings-store").then(({ useKeybindingsStore }) => {
65
+ useKeybindingsStore.getState().loadFromServer();
66
+ });
67
+ }, [fetchServerInfo]);
63
68
 
64
69
  // Auth check on mount
65
70
  useEffect(() => {
@@ -0,0 +1,182 @@
1
+ import { useState, useEffect, useCallback, useRef } from "react";
2
+ import { RotateCcw, AlertTriangle, Lock } from "lucide-react";
3
+ import { Button } from "@/components/ui/button";
4
+ import {
5
+ KEY_ACTIONS,
6
+ useKeybindingsStore,
7
+ formatCombo,
8
+ comboFromEvent,
9
+ type KeyCategory,
10
+ } from "@/stores/keybindings-store";
11
+
12
+ const CATEGORIES: { key: KeyCategory; label: string }[] = [
13
+ { key: "general", label: "General" },
14
+ { key: "tabs", label: "Tabs" },
15
+ { key: "projects", label: "Projects" },
16
+ ];
17
+
18
+ const BROWSER_RESERVED = [
19
+ "Ctrl+T", "Ctrl+W", "Ctrl+N", "Ctrl+Tab",
20
+ "Ctrl+L", "Ctrl+H", "Ctrl+J", "F5", "Ctrl+R",
21
+ "Ctrl+Shift+I", "Ctrl+Shift+J",
22
+ ];
23
+
24
+ /** A single shortcut badge — click to record, Escape to cancel */
25
+ function ShortcutBadge({
26
+ actionId,
27
+ combo,
28
+ locked,
29
+ }: {
30
+ actionId: string;
31
+ combo: string;
32
+ locked?: boolean;
33
+ }) {
34
+ const [recording, setRecording] = useState(false);
35
+ const setBinding = useKeybindingsStore((s) => s.setBinding);
36
+ const badgeRef = useRef<HTMLButtonElement>(null);
37
+
38
+ const handleRecord = useCallback(
39
+ (e: KeyboardEvent) => {
40
+ e.preventDefault();
41
+ e.stopPropagation();
42
+ if (e.key === "Escape") {
43
+ setRecording(false);
44
+ return;
45
+ }
46
+ const newCombo = comboFromEvent(e);
47
+ if (newCombo) {
48
+ setBinding(actionId, newCombo);
49
+ setRecording(false);
50
+ }
51
+ },
52
+ [actionId, setBinding],
53
+ );
54
+
55
+ useEffect(() => {
56
+ if (!recording) return;
57
+ document.addEventListener("keydown", handleRecord, true);
58
+ return () => document.removeEventListener("keydown", handleRecord, true);
59
+ }, [recording, handleRecord]);
60
+
61
+ // Close recording on outside click
62
+ useEffect(() => {
63
+ if (!recording) return;
64
+ const handler = (e: MouseEvent) => {
65
+ if (badgeRef.current && !badgeRef.current.contains(e.target as Node)) {
66
+ setRecording(false);
67
+ }
68
+ };
69
+ document.addEventListener("mousedown", handler);
70
+ return () => document.removeEventListener("mousedown", handler);
71
+ }, [recording]);
72
+
73
+ if (locked) {
74
+ return (
75
+ <span className="inline-flex items-center gap-1 rounded border border-border bg-muted px-2 py-0.5 text-[11px] font-mono text-muted-foreground">
76
+ <Lock className="size-2.5" />
77
+ {formatCombo(combo)}
78
+ </span>
79
+ );
80
+ }
81
+
82
+ if (recording) {
83
+ return (
84
+ <button
85
+ ref={badgeRef}
86
+ className="inline-flex items-center rounded border-2 border-primary bg-primary/10 px-2 py-0.5 text-[11px] font-mono text-primary animate-pulse"
87
+ >
88
+ Press keys...
89
+ </button>
90
+ );
91
+ }
92
+
93
+ return (
94
+ <button
95
+ ref={badgeRef}
96
+ onClick={() => setRecording(true)}
97
+ className="inline-flex items-center rounded border border-border bg-surface px-2 py-0.5 text-[11px] font-mono text-foreground hover:border-primary hover:bg-primary/5 transition-colors cursor-pointer"
98
+ title="Click to change shortcut"
99
+ >
100
+ {formatCombo(combo)}
101
+ </button>
102
+ );
103
+ }
104
+
105
+ export function KeyboardShortcutsSection() {
106
+ const { getBinding, resetBinding, resetAll, overrides } = useKeybindingsStore();
107
+
108
+ return (
109
+ <div className="space-y-3">
110
+ <div className="flex items-center justify-between">
111
+ <h3 className="text-xs font-medium text-text-secondary">Keyboard Shortcuts</h3>
112
+ {Object.keys(overrides).length > 0 && (
113
+ <Button
114
+ variant="ghost"
115
+ size="sm"
116
+ className="h-6 text-[10px] text-muted-foreground"
117
+ onClick={resetAll}
118
+ >
119
+ <RotateCcw className="size-3 mr-1" />
120
+ Reset all
121
+ </Button>
122
+ )}
123
+ </div>
124
+
125
+ {/* Browser warning */}
126
+ <div className="flex items-start gap-2 rounded-md border border-amber-500/30 bg-amber-500/5 px-2.5 py-2">
127
+ <AlertTriangle className="size-3.5 text-amber-500 shrink-0 mt-0.5" />
128
+ <p className="text-[10px] text-muted-foreground leading-relaxed">
129
+ Some shortcuts ({BROWSER_RESERVED.slice(0, 4).join(", ")}...) are reserved by the browser and cannot be overridden.
130
+ </p>
131
+ </div>
132
+
133
+ {/* Categories */}
134
+ {CATEGORIES.map((cat) => {
135
+ const actions = KEY_ACTIONS.filter((a) => a.category === cat.key);
136
+ if (actions.length === 0) return null;
137
+ return (
138
+ <div key={cat.key} className="space-y-1">
139
+ <span className="text-[10px] font-medium text-muted-foreground uppercase tracking-wide">
140
+ {cat.label}
141
+ </span>
142
+ <div className="space-y-0.5">
143
+ {actions.map((action) => {
144
+ const currentCombo = getBinding(action.id);
145
+ const isOverridden = action.id in overrides;
146
+ return (
147
+ <div
148
+ key={action.id}
149
+ className="flex items-center justify-between py-1 px-1 rounded hover:bg-surface-elevated/50 transition-colors"
150
+ >
151
+ <div className="flex flex-col min-w-0">
152
+ <span className="text-xs text-foreground">{action.label}</span>
153
+ {action.note && (
154
+ <span className="text-[10px] text-muted-foreground">{action.note}</span>
155
+ )}
156
+ </div>
157
+ <div className="flex items-center gap-1 shrink-0 ml-2">
158
+ <ShortcutBadge
159
+ actionId={action.id}
160
+ combo={currentCombo}
161
+ locked={action.locked}
162
+ />
163
+ {isOverridden && !action.locked && (
164
+ <button
165
+ onClick={() => resetBinding(action.id)}
166
+ className="flex items-center justify-center size-5 rounded text-muted-foreground hover:text-foreground hover:bg-surface-elevated transition-colors"
167
+ title="Reset to default"
168
+ >
169
+ <RotateCcw className="size-3" />
170
+ </button>
171
+ )}
172
+ </div>
173
+ </div>
174
+ );
175
+ })}
176
+ </div>
177
+ </div>
178
+ );
179
+ })}
180
+ </div>
181
+ );
182
+ }
@@ -4,6 +4,7 @@ import { Separator } from "@/components/ui/separator";
4
4
  import { useSettingsStore, type Theme } from "@/stores/settings-store";
5
5
  import { cn } from "@/lib/utils";
6
6
  import { AISettingsSection } from "./ai-settings-section";
7
+ import { KeyboardShortcutsSection } from "./keyboard-shortcuts-section";
7
8
  import { usePushNotification } from "@/hooks/use-push-notification";
8
9
 
9
10
  const THEME_OPTIONS: { value: Theme; label: string; icon: React.ElementType }[] = [
@@ -109,6 +110,10 @@ export function SettingsTab() {
109
110
 
110
111
  <Separator />
111
112
 
113
+ <KeyboardShortcutsSection />
114
+
115
+ <Separator />
116
+
112
117
  <div className="space-y-1.5">
113
118
  <h3 className="text-xs font-medium text-text-secondary">About</h3>
114
119
  <p className="text-xs text-text-secondary">
@@ -1,6 +1,8 @@
1
1
  import { useEffect, useState, useCallback } from "react";
2
2
  import { useTabStore } from "@/stores/tab-store";
3
3
  import { useSettingsStore } from "@/stores/settings-store";
4
+ import { useProjectStore } from "@/stores/project-store";
5
+ import { useKeybindingsStore } from "@/stores/keybindings-store";
4
6
 
5
7
  /** Dispatch this event to open the command palette from anywhere, optionally with initial query */
6
8
  export function openCommandPalette(initialQuery?: string) {
@@ -8,10 +10,10 @@ export function openCommandPalette(initialQuery?: string) {
8
10
  }
9
11
 
10
12
  /**
11
- * Global keyboard shortcuts.
13
+ * Global keyboard shortcuts — reads bindings from keybindings store.
12
14
  *
13
- * Shift+Shift (double tap) Open command palette
14
- * Alt+] / Alt+[ → Next / previous tab
15
+ * Shift+Shift (double tap) is always hardcoded (non-customizable).
16
+ * Everything else uses `matchesEvent()` from the keybindings store.
15
17
  */
16
18
  export function useGlobalKeybindings() {
17
19
  const [paletteOpen, setPaletteOpen] = useState(false);
@@ -19,9 +21,10 @@ export function useGlobalKeybindings() {
19
21
 
20
22
  useEffect(() => {
21
23
  let lastShiftUp = 0;
24
+ const { matchesEvent } = useKeybindingsStore.getState();
22
25
 
23
26
  function handler(e: KeyboardEvent) {
24
- // Double-Shift detection (on keyup to avoid repeats)
27
+ // Double-Shift detection (on keyup to avoid repeats) — always active
25
28
  if (e.type === "keyup" && e.key === "Shift" && !e.ctrlKey && !e.metaKey && !e.altKey) {
26
29
  const now = Date.now();
27
30
  if (now - lastShiftUp < 400) {
@@ -34,40 +37,97 @@ export function useGlobalKeybindings() {
34
37
  return;
35
38
  }
36
39
 
37
- // Keydown shortcuts
38
40
  if (e.type !== "keydown") return;
39
41
 
40
- // Cmd/Ctrl+S Prevent browser save dialog (editor auto-saves)
41
- if (e.key === "s" && (e.metaKey || e.ctrlKey) && !e.altKey && !e.shiftKey) {
42
+ // Re-read matchesEvent on each keydown to pick up live overrides
43
+ const { matchesEvent: match } = useKeybindingsStore.getState();
44
+
45
+ // Prevent browser save dialog (locked — always Mod+S)
46
+ if (match(e, "save-prevent")) {
42
47
  e.preventDefault();
43
48
  return;
44
49
  }
45
50
 
46
- // F1 → Open command palette
47
- if (e.key === "F1") {
51
+ // Command palette
52
+ if (match(e, "command-palette")) {
48
53
  e.preventDefault();
49
54
  setPaletteInitialQuery("");
50
55
  setPaletteOpen(true);
51
56
  return;
52
57
  }
53
58
 
54
- // Cmd/Ctrl+B → Toggle sidebar
55
- if (e.key === "b" && (e.metaKey || e.ctrlKey) && !e.altKey && !e.shiftKey) {
59
+ // Toggle sidebar
60
+ if (match(e, "toggle-sidebar")) {
56
61
  e.preventDefault();
57
62
  useSettingsStore.getState().toggleSidebar();
58
63
  return;
59
64
  }
60
65
 
61
- // Alt+] / Alt+[ → Cycle tabs
62
- if (e.altKey && !e.ctrlKey && !e.metaKey && (e.key === "]" || e.key === "[")) {
66
+ // Tab cycling
67
+ if (match(e, "next-tab") || match(e, "prev-tab")) {
63
68
  e.preventDefault();
64
69
  const { tabs, activeTabId, setActiveTab } = useTabStore.getState();
65
70
  if (tabs.length < 2) return;
66
71
  const idx = tabs.findIndex((t) => t.id === activeTabId);
67
- const next = e.key === "]"
72
+ const forward = match(e, "next-tab");
73
+ const next = forward
68
74
  ? (idx + 1) % tabs.length
69
75
  : (idx - 1 + tabs.length) % tabs.length;
70
76
  setActiveTab(tabs[next]!.id);
77
+ return;
78
+ }
79
+
80
+ // Open tab shortcuts
81
+ const tabShortcuts: { action: string; type: string; title: string }[] = [
82
+ { action: "open-chat", type: "chat", title: "AI Chat" },
83
+ { action: "open-terminal", type: "terminal", title: "Terminal" },
84
+ { action: "open-git-graph", type: "git-graph", title: "Git Graph" },
85
+ ];
86
+ for (const s of tabShortcuts) {
87
+ if (match(e, s.action)) {
88
+ e.preventDefault();
89
+ const project = useProjectStore.getState().activeProject;
90
+ useTabStore.getState().openTab({
91
+ type: s.type as any,
92
+ title: s.title,
93
+ projectId: project?.name ?? null,
94
+ metadata: project ? { projectName: project.name } : undefined,
95
+ closable: true,
96
+ });
97
+ return;
98
+ }
99
+ }
100
+
101
+ // Open settings (sidebar)
102
+ if (match(e, "open-settings")) {
103
+ e.preventDefault();
104
+ const settings = useSettingsStore.getState();
105
+ if (settings.sidebarCollapsed) settings.toggleSidebar();
106
+ settings.setSidebarActiveTab("settings");
107
+ return;
108
+ }
109
+
110
+ // Open git status (sidebar)
111
+ if (match(e, "open-git-status")) {
112
+ e.preventDefault();
113
+ const settings = useSettingsStore.getState();
114
+ if (settings.sidebarCollapsed) settings.toggleSidebar();
115
+ settings.setSidebarActiveTab("git");
116
+ return;
117
+ }
118
+
119
+ // Switch project 1-9
120
+ for (let i = 1; i <= 9; i++) {
121
+ if (match(e, `switch-project-${i}`)) {
122
+ e.preventDefault();
123
+ const projects = useProjectStore.getState().projects;
124
+ const target = projects[i - 1];
125
+ if (target) {
126
+ useProjectStore.getState().setActiveProject(target);
127
+ useTabStore.getState().switchProject(target.name);
128
+ }
129
+ return;
130
+ }
71
131
  }
72
132
  }
73
133
 
@@ -0,0 +1,192 @@
1
+ import { create } from "zustand";
2
+ import { api } from "@/lib/api-client";
3
+
4
+ // ---------------------------------------------------------------------------
5
+ // Types
6
+ // ---------------------------------------------------------------------------
7
+
8
+ export type KeyCategory = "general" | "tabs" | "projects";
9
+
10
+ export interface KeyAction {
11
+ id: string;
12
+ label: string;
13
+ category: KeyCategory;
14
+ defaultKey: string;
15
+ locked?: boolean;
16
+ note?: string;
17
+ }
18
+
19
+ // ---------------------------------------------------------------------------
20
+ // Action catalog
21
+ // ---------------------------------------------------------------------------
22
+
23
+ const isMac = typeof navigator !== "undefined" && /Mac|iPhone|iPad/.test(navigator.userAgent);
24
+
25
+ export const KEY_ACTIONS: KeyAction[] = [
26
+ // General
27
+ { id: "command-palette", label: "Command Palette", category: "general", defaultKey: "F1", note: "Shift+Shift also opens (not customizable)" },
28
+ { id: "toggle-sidebar", label: "Toggle Sidebar", category: "general", defaultKey: "Mod+B" },
29
+ { id: "save-prevent", label: "Prevent Save Dialog", category: "general", defaultKey: "Mod+S", locked: true, note: "Always active — prevents browser save" },
30
+ // Tabs
31
+ { id: "next-tab", label: "Next Tab", category: "tabs", defaultKey: "Alt+]" },
32
+ { id: "prev-tab", label: "Previous Tab", category: "tabs", defaultKey: "Alt+[" },
33
+ { id: "open-chat", label: "Open Chat", category: "tabs", defaultKey: "Mod+Shift+L" },
34
+ { id: "open-terminal", label: "Open Terminal", category: "tabs", defaultKey: "Mod+`" },
35
+ { id: "open-settings", label: "Open Settings", category: "tabs", defaultKey: "Mod+," },
36
+ { id: "open-git-graph", label: "Git Graph", category: "tabs", defaultKey: "Mod+Shift+G" },
37
+ { id: "open-git-status", label: "Git Status (sidebar)", category: "tabs", defaultKey: "Mod+Shift+E" },
38
+ // Projects — Mod+1..9
39
+ ...Array.from({ length: 9 }, (_, i) => ({
40
+ id: `switch-project-${i + 1}`,
41
+ label: `Switch to Project ${i + 1}`,
42
+ category: "projects" as KeyCategory,
43
+ defaultKey: `Mod+${i + 1}`,
44
+ })),
45
+ ];
46
+
47
+ /** Map action ID → default key for fast lookup */
48
+ const DEFAULT_MAP = new Map(KEY_ACTIONS.map((a) => [a.id, a.defaultKey]));
49
+
50
+ // ---------------------------------------------------------------------------
51
+ // Key combo parsing & matching
52
+ // ---------------------------------------------------------------------------
53
+
54
+ interface ParsedCombo {
55
+ ctrl: boolean;
56
+ meta: boolean;
57
+ alt: boolean;
58
+ shift: boolean;
59
+ key: string; // lowercase
60
+ }
61
+
62
+ function parseCombo(combo: string): ParsedCombo {
63
+ const parts = combo.split("+");
64
+ const result: ParsedCombo = { ctrl: false, meta: false, alt: false, shift: false, key: "" };
65
+ for (const part of parts) {
66
+ const p = part.trim();
67
+ switch (p) {
68
+ case "Mod":
69
+ if (isMac) result.meta = true;
70
+ else result.ctrl = true;
71
+ break;
72
+ case "Ctrl": result.ctrl = true; break;
73
+ case "Meta": case "Cmd": result.meta = true; break;
74
+ case "Alt": result.alt = true; break;
75
+ case "Shift": result.shift = true; break;
76
+ default: result.key = p.toLowerCase(); break;
77
+ }
78
+ }
79
+ return result;
80
+ }
81
+
82
+ function eventMatchesCombo(e: KeyboardEvent, combo: ParsedCombo): boolean {
83
+ if (e.ctrlKey !== combo.ctrl) return false;
84
+ if (e.metaKey !== combo.meta) return false;
85
+ if (e.altKey !== combo.alt) return false;
86
+ if (e.shiftKey !== combo.shift) return false;
87
+ return e.key.toLowerCase() === combo.key;
88
+ }
89
+
90
+ // ---------------------------------------------------------------------------
91
+ // Format combo for display
92
+ // ---------------------------------------------------------------------------
93
+
94
+ export function formatCombo(combo: string): string {
95
+ return combo
96
+ .replace(/Mod/g, isMac ? "\u2318" : "Ctrl")
97
+ .replace(/Shift/g, isMac ? "\u21E7" : "Shift")
98
+ .replace(/Alt/g, isMac ? "\u2325" : "Alt")
99
+ .replace(/Meta|Cmd/g, "\u2318")
100
+ .replace(/Ctrl/g, isMac ? "\u2303" : "Ctrl");
101
+ }
102
+
103
+ /** Build combo string from a KeyboardEvent (for recording) */
104
+ export function comboFromEvent(e: KeyboardEvent): string | null {
105
+ // Ignore bare modifier keys
106
+ if (["Control", "Meta", "Alt", "Shift"].includes(e.key)) return null;
107
+
108
+ const parts: string[] = [];
109
+ if (e.ctrlKey && !e.metaKey) parts.push(isMac ? "Ctrl" : "Mod");
110
+ if (e.metaKey) parts.push(isMac ? "Mod" : "Meta");
111
+ if (e.altKey) parts.push("Alt");
112
+ if (e.shiftKey) parts.push("Shift");
113
+
114
+ // Normalize key
115
+ let key = e.key;
116
+ if (key === " ") key = "Space";
117
+ else if (key.length === 1) key = key.toUpperCase();
118
+ parts.push(key);
119
+
120
+ return parts.join("+");
121
+ }
122
+
123
+ // ---------------------------------------------------------------------------
124
+ // Store
125
+ // ---------------------------------------------------------------------------
126
+
127
+ interface KeybindingsState {
128
+ /** User overrides from server (action ID → combo string) */
129
+ overrides: Record<string, string>;
130
+ loaded: boolean;
131
+
132
+ /** Get the effective binding for an action */
133
+ getBinding: (actionId: string) => string;
134
+ /** Check if a keyboard event matches an action */
135
+ matchesEvent: (e: KeyboardEvent, actionId: string) => boolean;
136
+ /** Set a custom binding (persists to server) */
137
+ setBinding: (actionId: string, combo: string) => void;
138
+ /** Reset a single binding to default (persists to server) */
139
+ resetBinding: (actionId: string) => void;
140
+ /** Reset all bindings to defaults (persists to server) */
141
+ resetAll: () => void;
142
+ /** Load overrides from server */
143
+ loadFromServer: () => Promise<void>;
144
+ }
145
+
146
+ export const useKeybindingsStore = create<KeybindingsState>((set, get) => ({
147
+ overrides: {},
148
+ loaded: false,
149
+
150
+ getBinding: (actionId) => {
151
+ return get().overrides[actionId] ?? DEFAULT_MAP.get(actionId) ?? "";
152
+ },
153
+
154
+ matchesEvent: (e, actionId) => {
155
+ const combo = get().getBinding(actionId);
156
+ if (!combo) return false;
157
+ return eventMatchesCombo(e, parseCombo(combo));
158
+ },
159
+
160
+ setBinding: (actionId, combo) => {
161
+ const newOverrides = { ...get().overrides, [actionId]: combo };
162
+ set({ overrides: newOverrides });
163
+ // Persist to server (fire-and-forget)
164
+ api.put("/api/settings/keybindings", { [actionId]: combo }).catch(() => {});
165
+ },
166
+
167
+ resetBinding: (actionId) => {
168
+ const newOverrides = { ...get().overrides };
169
+ delete newOverrides[actionId];
170
+ set({ overrides: newOverrides });
171
+ api.put("/api/settings/keybindings", { [actionId]: null }).catch(() => {});
172
+ },
173
+
174
+ resetAll: () => {
175
+ set({ overrides: {} });
176
+ // Send all current override keys as null to clear them
177
+ const nulled: Record<string, null> = {};
178
+ for (const key of Object.keys(get().overrides)) nulled[key] = null;
179
+ if (Object.keys(nulled).length > 0) {
180
+ api.put("/api/settings/keybindings", nulled).catch(() => {});
181
+ }
182
+ },
183
+
184
+ loadFromServer: async () => {
185
+ try {
186
+ const overrides = await api.get<Record<string, string>>("/api/settings/keybindings");
187
+ set({ overrides, loaded: true });
188
+ } catch {
189
+ set({ loaded: true }); // proceed with defaults on error
190
+ }
191
+ },
192
+ }));
@@ -1 +0,0 @@
1
- import{a as e}from"./jsx-runtime-B4BJKQ1u.js";var t=e({api:()=>r,getAuthToken:()=>o,projectUrl:()=>i,setAuthToken:()=>a}),n=`ppm-auth-token`,r=new class{baseUrl;constructor(e=``){this.baseUrl=e}getToken(){return localStorage.getItem(n)}headers(){let e={"Content-Type":`application/json`},t=this.getToken();return t&&(e.Authorization=`Bearer ${t}`),e}async get(e){let t=await fetch(`${this.baseUrl}${e}`,{headers:this.headers()});return this.handleResponse(t)}async post(e,t){let n=await fetch(`${this.baseUrl}${e}`,{method:`POST`,headers:this.headers(),body:t==null?void 0:JSON.stringify(t)});return this.handleResponse(n)}async put(e,t){let n=await fetch(`${this.baseUrl}${e}`,{method:`PUT`,headers:this.headers(),body:t==null?void 0:JSON.stringify(t)});return this.handleResponse(n)}async patch(e,t){let n=await fetch(`${this.baseUrl}${e}`,{method:`PATCH`,headers:this.headers(),body:t==null?void 0:JSON.stringify(t)});return this.handleResponse(n)}async del(e,t){let n=await fetch(`${this.baseUrl}${e}`,{method:`DELETE`,headers:this.headers(),body:t==null?void 0:JSON.stringify(t)});await this.handleResponse(n)}async handleResponse(e){if(e.status===401)throw localStorage.removeItem(n),window.location.reload(),Error(`Unauthorized`);let t=await e.json();if(t.ok===!1)throw Error(t.error??`HTTP ${e.status}`);return t.data}};function i(e){return`/api/project/${encodeURIComponent(e)}`}function a(e){localStorage.setItem(n,e)}function o(){return localStorage.getItem(n)}export{a,i,t as n,o as r,r as t};