@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.
Files changed (74) hide show
  1. package/CHANGELOG.md +16 -0
  2. package/dist/web/assets/api-client-BHpHp5Lz.js +1 -0
  3. package/dist/web/assets/{chat-tab-BdiG3Gnr.js → chat-tab-CDVCDw_H.js} +5 -5
  4. package/dist/web/assets/code-editor-wmS73ejX.js +1 -0
  5. package/dist/web/assets/diff-viewer-BsYccTx1.js +4 -0
  6. package/dist/web/assets/{dist-CJbcT4CK.js → dist-PpKqMvyx.js} +2 -2
  7. package/dist/web/assets/git-graph-BbWb6_Jq.js +1 -0
  8. package/dist/web/assets/index-DhuAmTQ1.js +21 -0
  9. package/dist/web/assets/index-aIGuIMQ8.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-BqgrTQAC.js +1 -0
  13. package/dist/web/assets/{markdown-renderer-BPKEwysz.js → markdown-renderer-aPdw9BhU.js} +1 -1
  14. package/dist/web/assets/postgres-viewer-V4hKmmzV.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-DgOSmeGL.js +1 -0
  18. package/dist/web/assets/settings-tab-DwsKpk9T.js +1 -0
  19. package/dist/web/assets/sqlite-viewer-BRsj8GXc.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-3tDV4RCn.js} +1 -1
  22. package/dist/web/assets/{use-monaco-theme-yxUtuNlu.js → use-monaco-theme-Ccqh1RD4.js} +1 -1
  23. package/dist/web/index.html +9 -8
  24. package/dist/web/sw.js +1 -1
  25. package/docs/codebase-summary.md +41 -14
  26. package/docs/project-roadmap.md +31 -6
  27. package/docs/system-architecture.md +222 -7
  28. package/package.json +1 -1
  29. package/src/cli/commands/db-cmd.ts +355 -0
  30. package/src/server/index.ts +6 -0
  31. package/src/server/routes/database.ts +259 -0
  32. package/src/server/routes/settings.ts +33 -0
  33. package/src/services/database/adapter-registry.ts +13 -0
  34. package/src/services/database/init-adapters.ts +9 -0
  35. package/src/services/database/postgres-adapter.ts +42 -0
  36. package/src/services/database/readonly-check.ts +17 -0
  37. package/src/services/database/sqlite-adapter.ts +55 -0
  38. package/src/services/db.service.ts +173 -2
  39. package/src/services/table-cache.service.ts +75 -0
  40. package/src/types/database.ts +50 -0
  41. package/src/web/app.tsx +11 -1
  42. package/src/web/components/database/connection-color-picker.tsx +67 -0
  43. package/src/web/components/database/connection-form-dialog.tsx +234 -0
  44. package/src/web/components/database/connection-list.tsx +208 -0
  45. package/src/web/components/database/database-sidebar.tsx +100 -0
  46. package/src/web/components/database/use-connections.ts +99 -0
  47. package/src/web/components/layout/command-palette.tsx +57 -6
  48. package/src/web/components/layout/draggable-tab.tsx +13 -2
  49. package/src/web/components/layout/mobile-drawer.tsx +7 -2
  50. package/src/web/components/layout/sidebar.tsx +6 -1
  51. package/src/web/components/postgres/postgres-viewer.tsx +12 -3
  52. package/src/web/components/postgres/use-postgres.ts +57 -21
  53. package/src/web/components/settings/keyboard-shortcuts-section.tsx +182 -0
  54. package/src/web/components/settings/settings-tab.tsx +5 -0
  55. package/src/web/components/sqlite/sqlite-viewer.tsx +27 -3
  56. package/src/web/components/sqlite/use-sqlite.ts +21 -12
  57. package/src/web/hooks/use-global-keybindings.ts +74 -14
  58. package/src/web/lib/api-client.ts +7 -1
  59. package/src/web/lib/color-utils.ts +23 -0
  60. package/src/web/stores/keybindings-store.ts +192 -0
  61. package/src/web/stores/settings-store.ts +2 -2
  62. package/dist/web/assets/api-client-DPWUomlf.js +0 -1
  63. package/dist/web/assets/code-editor-soN1frMc.js +0 -1
  64. package/dist/web/assets/diff-viewer-DJEB1zOd.js +0 -4
  65. package/dist/web/assets/git-graph-CrU7vGxw.js +0 -1
  66. package/dist/web/assets/index-CmrE0Xoy.js +0 -21
  67. package/dist/web/assets/index-g11aaU-x.css +0 -2
  68. package/dist/web/assets/input-DMu1FA4M.js +0 -41
  69. package/dist/web/assets/postgres-viewer-lBV4F44Q.js +0 -1
  70. package/dist/web/assets/react-Bo97Lrzq.js +0 -1
  71. package/dist/web/assets/rotate-ccw-Dx0ShAKj.js +0 -1
  72. package/dist/web/assets/settings-store-BRFvbsHd.js +0 -1
  73. package/dist/web/assets/settings-tab-Div5NL2d.js +0 -1
  74. 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; queryPanelOpen: boolean; onToggleQueryPanel: () => void;
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
- const base = `${projectUrl(projectName)}/sqlite`;
22
- const qs = `path=${encodeURIComponent(dbPath)}`;
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 data = await api.get<TableInfo[]>(`${base}/tables?${qs}`);
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?${qs}&table=${encodeURIComponent(selectedTable)}&page=${page}&limit=100`),
48
- api.get<ColumnInfo[]>(`${base}/schema?${qs}&table=${encodeURIComponent(selectedTable)}`),
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 result = await api.post<QueryResult>(`${base}/query`, { path: dbPath, sql });
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(); // Refresh table after modification
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
- await api.put(`${base}/cell`, { path: dbPath, table: selectedTable, rowid, column, value });
85
- fetchTableData(); // Refresh
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) 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
 
@@ -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
- window.location.reload();
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
+ }