@hienlh/ppm 0.6.2 → 0.6.4
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 +16 -0
- package/dist/web/assets/api-client-BHpHp5Lz.js +1 -0
- package/dist/web/assets/{chat-tab-BdiG3Gnr.js → chat-tab-CDVCDw_H.js} +5 -5
- package/dist/web/assets/code-editor-wmS73ejX.js +1 -0
- package/dist/web/assets/diff-viewer-BsYccTx1.js +4 -0
- package/dist/web/assets/{dist-CJbcT4CK.js → dist-PpKqMvyx.js} +2 -2
- package/dist/web/assets/git-graph-BbWb6_Jq.js +1 -0
- package/dist/web/assets/index-DhuAmTQ1.js +21 -0
- package/dist/web/assets/index-aIGuIMQ8.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-BqgrTQAC.js +1 -0
- package/dist/web/assets/{markdown-renderer-BPKEwysz.js → markdown-renderer-aPdw9BhU.js} +1 -1
- package/dist/web/assets/postgres-viewer-V4hKmmzV.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-DgOSmeGL.js +1 -0
- package/dist/web/assets/settings-tab-DwsKpk9T.js +1 -0
- package/dist/web/assets/sqlite-viewer-BRsj8GXc.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-3tDV4RCn.js} +1 -1
- package/dist/web/assets/{use-monaco-theme-yxUtuNlu.js → use-monaco-theme-Ccqh1RD4.js} +1 -1
- package/dist/web/index.html +9 -8
- package/dist/web/sw.js +1 -1
- package/docs/codebase-summary.md +41 -14
- package/docs/project-roadmap.md +31 -6
- package/docs/system-architecture.md +222 -7
- package/package.json +1 -1
- package/src/cli/commands/db-cmd.ts +355 -0
- package/src/server/index.ts +6 -0
- package/src/server/routes/database.ts +259 -0
- package/src/server/routes/settings.ts +33 -0
- package/src/services/database/adapter-registry.ts +13 -0
- package/src/services/database/init-adapters.ts +9 -0
- package/src/services/database/postgres-adapter.ts +42 -0
- package/src/services/database/readonly-check.ts +17 -0
- package/src/services/database/sqlite-adapter.ts +55 -0
- package/src/services/db.service.ts +173 -2
- package/src/services/table-cache.service.ts +75 -0
- package/src/types/database.ts +50 -0
- package/src/web/app.tsx +11 -1
- package/src/web/components/database/connection-color-picker.tsx +67 -0
- package/src/web/components/database/connection-form-dialog.tsx +234 -0
- package/src/web/components/database/connection-list.tsx +208 -0
- package/src/web/components/database/database-sidebar.tsx +100 -0
- package/src/web/components/database/use-connections.ts +99 -0
- package/src/web/components/layout/command-palette.tsx +57 -6
- package/src/web/components/layout/draggable-tab.tsx +13 -2
- package/src/web/components/layout/mobile-drawer.tsx +7 -2
- package/src/web/components/layout/sidebar.tsx +6 -1
- package/src/web/components/postgres/postgres-viewer.tsx +12 -3
- package/src/web/components/postgres/use-postgres.ts +57 -21
- package/src/web/components/settings/keyboard-shortcuts-section.tsx +182 -0
- package/src/web/components/settings/settings-tab.tsx +5 -0
- package/src/web/components/sqlite/sqlite-viewer.tsx +27 -3
- package/src/web/components/sqlite/use-sqlite.ts +21 -12
- package/src/web/hooks/use-global-keybindings.ts +74 -14
- package/src/web/lib/api-client.ts +7 -1
- package/src/web/lib/color-utils.ts +23 -0
- package/src/web/stores/keybindings-store.ts +192 -0
- package/src/web/stores/settings-store.ts +2 -2
- 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-store-BRFvbsHd.js +0 -1
- package/dist/web/assets/settings-tab-Div5NL2d.js +0 -1
- package/dist/web/assets/sqlite-viewer-BbgWU-v3.js +0 -1
|
@@ -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">
|
|
@@ -13,8 +13,24 @@ interface SqliteViewerProps {
|
|
|
13
13
|
export function SqliteViewer({ metadata }: SqliteViewerProps) {
|
|
14
14
|
const filePath = metadata?.filePath as string | undefined;
|
|
15
15
|
const projectName = metadata?.projectName as string | undefined;
|
|
16
|
+
const connectionId = metadata?.connectionId as number | undefined;
|
|
17
|
+
const initialTable = metadata?.tableName as string | undefined;
|
|
16
18
|
const [queryPanelOpen, setQueryPanelOpen] = useState(false);
|
|
17
19
|
|
|
20
|
+
// Connection-based mode: skip file selection requirement
|
|
21
|
+
if (connectionId) {
|
|
22
|
+
return (
|
|
23
|
+
<SqliteViewerInner
|
|
24
|
+
projectName=""
|
|
25
|
+
dbPath=""
|
|
26
|
+
connectionId={connectionId}
|
|
27
|
+
initialTable={initialTable}
|
|
28
|
+
queryPanelOpen={queryPanelOpen}
|
|
29
|
+
onToggleQueryPanel={() => setQueryPanelOpen((v) => !v)}
|
|
30
|
+
/>
|
|
31
|
+
);
|
|
32
|
+
}
|
|
33
|
+
|
|
18
34
|
if (!filePath || !projectName) {
|
|
19
35
|
return (
|
|
20
36
|
<div className="flex items-center justify-center h-full text-text-secondary text-sm">
|
|
@@ -34,11 +50,19 @@ export function SqliteViewer({ metadata }: SqliteViewerProps) {
|
|
|
34
50
|
}
|
|
35
51
|
|
|
36
52
|
function SqliteViewerInner({
|
|
37
|
-
projectName, dbPath, queryPanelOpen, onToggleQueryPanel,
|
|
53
|
+
projectName, dbPath, connectionId, initialTable, queryPanelOpen, onToggleQueryPanel,
|
|
38
54
|
}: {
|
|
39
|
-
projectName: string; dbPath: string;
|
|
55
|
+
projectName: string; dbPath: string; connectionId?: number; initialTable?: string;
|
|
56
|
+
queryPanelOpen: boolean; onToggleQueryPanel: () => void;
|
|
40
57
|
}) {
|
|
41
|
-
const sqlite = useSqlite(projectName, dbPath);
|
|
58
|
+
const sqlite = useSqlite(projectName, dbPath, connectionId);
|
|
59
|
+
|
|
60
|
+
// Jump to initial table from sidebar click
|
|
61
|
+
const [didInit, setDidInit] = useState(false);
|
|
62
|
+
if (initialTable && !didInit && sqlite.tables.length > 0 && sqlite.selectedTable !== initialTable) {
|
|
63
|
+
setDidInit(true);
|
|
64
|
+
sqlite.selectTable(initialTable);
|
|
65
|
+
}
|
|
42
66
|
|
|
43
67
|
if (sqlite.error && sqlite.tables.length === 0) {
|
|
44
68
|
return (
|
|
@@ -6,7 +6,7 @@ export interface ColumnInfo { cid: number; name: string; type: string; notnull:
|
|
|
6
6
|
export interface QueryResult { columns: string[]; rows: Record<string, unknown>[]; rowsAffected: number; changeType: "select" | "modify" }
|
|
7
7
|
interface TableData { columns: string[]; rows: Record<string, unknown>[]; total: number; page: number; limit: number }
|
|
8
8
|
|
|
9
|
-
export function useSqlite(projectName: string, dbPath: string) {
|
|
9
|
+
export function useSqlite(projectName: string, dbPath: string, connectionId?: number) {
|
|
10
10
|
const [tables, setTables] = useState<TableInfo[]>([]);
|
|
11
11
|
const [selectedTable, setSelectedTable] = useState<string | null>(null);
|
|
12
12
|
const [tableData, setTableData] = useState<TableData | null>(null);
|
|
@@ -18,15 +18,18 @@ export function useSqlite(projectName: string, dbPath: string) {
|
|
|
18
18
|
const [queryError, setQueryError] = useState<string | null>(null);
|
|
19
19
|
const [queryLoading, setQueryLoading] = useState(false);
|
|
20
20
|
|
|
21
|
-
|
|
22
|
-
const
|
|
21
|
+
// When connectionId present, use unified API; otherwise use project-scoped API
|
|
22
|
+
const unifiedBase = connectionId ? `/api/db/connections/${connectionId}` : null;
|
|
23
|
+
const base = unifiedBase ?? `${projectUrl(projectName)}/sqlite`;
|
|
24
|
+
const qs = unifiedBase ? "" : `path=${encodeURIComponent(dbPath)}`;
|
|
23
25
|
|
|
24
26
|
// Fetch tables on mount
|
|
25
27
|
const fetchTables = useCallback(async () => {
|
|
26
28
|
setLoading(true);
|
|
27
29
|
setError(null);
|
|
28
30
|
try {
|
|
29
|
-
const
|
|
31
|
+
const qsPart = qs ? `?${qs}` : "";
|
|
32
|
+
const data = await api.get<TableInfo[]>(`${base}/tables${qsPart}`);
|
|
30
33
|
setTables(data);
|
|
31
34
|
if (data.length > 0 && !selectedTable) setSelectedTable(data[0]!.name);
|
|
32
35
|
} catch (e) {
|
|
@@ -43,9 +46,10 @@ export function useSqlite(projectName: string, dbPath: string) {
|
|
|
43
46
|
if (!selectedTable) return;
|
|
44
47
|
setLoading(true);
|
|
45
48
|
try {
|
|
49
|
+
const qsPrefix = qs ? `${qs}&` : "";
|
|
46
50
|
const [data, cols] = await Promise.all([
|
|
47
|
-
api.get<TableData>(`${base}/data?${
|
|
48
|
-
api.get<ColumnInfo[]>(`${base}/schema?${
|
|
51
|
+
api.get<TableData>(`${base}/data?${qsPrefix}table=${encodeURIComponent(selectedTable)}&page=${page}&limit=100`),
|
|
52
|
+
api.get<ColumnInfo[]>(`${base}/schema?${qsPrefix}table=${encodeURIComponent(selectedTable)}`),
|
|
49
53
|
]);
|
|
50
54
|
setTableData(data);
|
|
51
55
|
setSchema(cols);
|
|
@@ -68,25 +72,30 @@ export function useSqlite(projectName: string, dbPath: string) {
|
|
|
68
72
|
setQueryLoading(true);
|
|
69
73
|
setQueryError(null);
|
|
70
74
|
try {
|
|
71
|
-
const
|
|
75
|
+
const body = unifiedBase ? { sql } : { path: dbPath, sql };
|
|
76
|
+
const result = await api.post<QueryResult>(`${base}/query`, body);
|
|
72
77
|
setQueryResult(result);
|
|
73
|
-
if (result.changeType === "modify") fetchTableData();
|
|
78
|
+
if (result.changeType === "modify") fetchTableData();
|
|
74
79
|
} catch (e) {
|
|
75
80
|
setQueryError((e as Error).message);
|
|
76
81
|
} finally {
|
|
77
82
|
setQueryLoading(false);
|
|
78
83
|
}
|
|
79
|
-
}, [base, dbPath, fetchTableData]);
|
|
84
|
+
}, [base, unifiedBase, dbPath, fetchTableData]);
|
|
80
85
|
|
|
81
86
|
const updateCell = useCallback(async (rowid: number, column: string, value: unknown) => {
|
|
82
87
|
if (!selectedTable) return;
|
|
83
88
|
try {
|
|
84
|
-
|
|
85
|
-
|
|
89
|
+
if (unifiedBase) {
|
|
90
|
+
await api.put(`${base}/cell`, { table: selectedTable, pkColumn: "rowid", pkValue: rowid, column, value });
|
|
91
|
+
} else {
|
|
92
|
+
await api.put(`${base}/cell`, { path: dbPath, table: selectedTable, rowid, column, value });
|
|
93
|
+
}
|
|
94
|
+
fetchTableData();
|
|
86
95
|
} catch (e) {
|
|
87
96
|
setError((e as Error).message);
|
|
88
97
|
}
|
|
89
|
-
}, [base, dbPath, selectedTable, fetchTableData]);
|
|
98
|
+
}, [base, unifiedBase, dbPath, selectedTable, fetchTableData]);
|
|
90
99
|
|
|
91
100
|
return {
|
|
92
101
|
tables, selectedTable, selectTable, tableData, schema,
|
|
@@ -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
|
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
const TOKEN_KEY = "ppm-auth-token";
|
|
2
|
+
const RELOAD_GUARD_KEY = "ppm-auth-reload-ts";
|
|
2
3
|
|
|
3
4
|
class ApiClient {
|
|
4
5
|
private baseUrl: string;
|
|
@@ -65,7 +66,12 @@ class ApiClient {
|
|
|
65
66
|
private async handleResponse<T>(res: Response): Promise<T> {
|
|
66
67
|
if (res.status === 401) {
|
|
67
68
|
localStorage.removeItem(TOKEN_KEY);
|
|
68
|
-
|
|
69
|
+
// Guard against infinite reload loops: skip reload if we already reloaded within 3s
|
|
70
|
+
const lastReload = Number(sessionStorage.getItem(RELOAD_GUARD_KEY) || "0");
|
|
71
|
+
if (Date.now() - lastReload > 3000) {
|
|
72
|
+
sessionStorage.setItem(RELOAD_GUARD_KEY, String(Date.now()));
|
|
73
|
+
window.location.reload();
|
|
74
|
+
}
|
|
69
75
|
throw new Error("Unauthorized");
|
|
70
76
|
}
|
|
71
77
|
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/** Parse hex color to RGB components */
|
|
2
|
+
function hexToRgb(hex: string): { r: number; g: number; b: number } | null {
|
|
3
|
+
const m = /^#([0-9a-fA-F]{6})$/.exec(hex.trim());
|
|
4
|
+
if (!m) return null;
|
|
5
|
+
const n = parseInt(m[1]!, 16);
|
|
6
|
+
return { r: (n >> 16) & 0xff, g: (n >> 8) & 0xff, b: n & 0xff };
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
/** Relative luminance per WCAG 2.0 */
|
|
10
|
+
function getLuminance(r: number, g: number, b: number): number {
|
|
11
|
+
const [rs, gs, bs] = [r, g, b].map((c) => {
|
|
12
|
+
const s = c / 255;
|
|
13
|
+
return s <= 0.04045 ? s / 12.92 : Math.pow((s + 0.055) / 1.055, 2.4);
|
|
14
|
+
}) as [number, number, number];
|
|
15
|
+
return 0.2126 * rs + 0.7152 * gs + 0.0722 * bs;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** Returns true if the color is dark enough to need white text */
|
|
19
|
+
export function isDarkColor(hex: string): boolean {
|
|
20
|
+
const rgb = hexToRgb(hex);
|
|
21
|
+
if (!rgb) return false;
|
|
22
|
+
return getLuminance(rgb.r, rgb.g, rgb.b) < 0.4;
|
|
23
|
+
}
|