@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.
- package/CHANGELOG.md +10 -0
- package/dist/web/assets/api-client-D0pZeYY8.js +1 -0
- package/dist/web/assets/{chat-tab-BdiG3Gnr.js → chat-tab-DjE_8Csw.js} +5 -5
- package/dist/web/assets/code-editor-witrClmz.js +1 -0
- package/dist/web/assets/diff-viewer-DSU--yFW.js +4 -0
- package/dist/web/assets/{dist-CJbcT4CK.js → dist-PpKqMvyx.js} +2 -2
- package/dist/web/assets/git-graph-HpcOYt3G.js +1 -0
- package/dist/web/assets/index-CcXQ5iQw.js +21 -0
- package/dist/web/assets/index-DyEgsogR.css +2 -0
- package/dist/web/assets/input-CCCPR1s4.js +41 -0
- package/dist/web/assets/jsx-runtime-wQxeESYQ.js +1 -0
- package/dist/web/assets/keybindings-store-C_KQKrsc.js +1 -0
- package/dist/web/assets/{markdown-renderer-BPKEwysz.js → markdown-renderer-DSw-4oxk.js} +1 -1
- package/dist/web/assets/postgres-viewer-BnkGPi0L.js +1 -0
- package/dist/web/assets/{jsx-runtime-B4BJKQ1u.js → react-CYzKIDNi.js} +1 -1
- package/dist/web/assets/react-l9v2XLcs.js +1 -0
- package/dist/web/assets/{settings-store-BRFvbsHd.js → settings-store-B5g1Gis-.js} +1 -1
- package/dist/web/assets/settings-tab-DpQdg9OW.js +1 -0
- package/dist/web/assets/sqlite-viewer-JZvegGV-.js +1 -0
- package/dist/web/assets/{tab-store-Bf9z6T8D.js → tab-store-DhXold0e.js} +1 -1
- package/dist/web/assets/{terminal-tab-Dt9bjwC8.js → terminal-tab-CAQvs2wj.js} +1 -1
- package/dist/web/assets/{use-monaco-theme-yxUtuNlu.js → use-monaco-theme-GX0lrqac.js} +1 -1
- package/dist/web/index.html +9 -8
- package/dist/web/sw.js +1 -1
- package/package.json +1 -1
- package/src/cli/commands/db-cmd.ts +338 -0
- package/src/server/routes/settings.ts +33 -0
- package/src/services/db.service.ts +99 -1
- package/src/web/app.tsx +7 -2
- package/src/web/components/settings/keyboard-shortcuts-section.tsx +182 -0
- package/src/web/components/settings/settings-tab.tsx +5 -0
- package/src/web/hooks/use-global-keybindings.ts +74 -14
- package/src/web/stores/keybindings-store.ts +192 -0
- package/dist/web/assets/api-client-DPWUomlf.js +0 -1
- package/dist/web/assets/code-editor-soN1frMc.js +0 -1
- package/dist/web/assets/diff-viewer-DJEB1zOd.js +0 -4
- package/dist/web/assets/git-graph-CrU7vGxw.js +0 -1
- package/dist/web/assets/index-CmrE0Xoy.js +0 -21
- package/dist/web/assets/index-g11aaU-x.css +0 -2
- package/dist/web/assets/input-DMu1FA4M.js +0 -41
- package/dist/web/assets/postgres-viewer-lBV4F44Q.js +0 -1
- package/dist/web/assets/react-Bo97Lrzq.js +0 -1
- package/dist/web/assets/rotate-ccw-Dx0ShAKj.js +0 -1
- package/dist/web/assets/settings-tab-Div5NL2d.js +0 -1
- 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 =
|
|
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(() => {
|
|
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)
|
|
14
|
-
*
|
|
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
|
-
//
|
|
41
|
-
|
|
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
|
-
//
|
|
47
|
-
if (e
|
|
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
|
-
//
|
|
55
|
-
if (e
|
|
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
|
-
//
|
|
62
|
-
if (
|
|
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
|
|
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};
|