@hienlh/ppm 0.2.20 → 0.2.21

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 (82) hide show
  1. package/CHANGELOG.md +31 -0
  2. package/CLAUDE.md +18 -1
  3. package/bun.lock +57 -59
  4. package/dist/ppm +0 -0
  5. package/dist/web/assets/chat-tab-C_U7EwM9.js +6 -0
  6. package/dist/web/assets/code-editor-DuarTBEe.js +1 -0
  7. package/dist/web/assets/diff-viewer-sBWBgb7U.js +4 -0
  8. package/dist/web/assets/git-graph-fOKEZiot.js +1 -0
  9. package/dist/web/assets/index-3zt5mBwZ.css +2 -0
  10. package/dist/web/assets/index-CaUQy3Zs.js +21 -0
  11. package/dist/web/assets/input-CTnwfHVN.js +41 -0
  12. package/dist/web/assets/settings-tab-C5aWMqIA.js +1 -0
  13. package/dist/web/assets/{terminal-tab-sGlqTp7k.js → terminal-tab-BEFAYT4S.js} +1 -1
  14. package/dist/web/assets/use-monaco-theme-BxaccPmI.js +11 -0
  15. package/dist/web/index.html +35 -8
  16. package/dist/web/sw.js +1 -1
  17. package/docs/codebase-summary.md +13 -8
  18. package/docs/project-roadmap.md +22 -4
  19. package/docs/system-architecture.md +59 -0
  20. package/package.json +6 -14
  21. package/src/providers/claude-agent-sdk.ts +2 -2
  22. package/src/providers/registry.ts +12 -11
  23. package/src/server/routes/projects.ts +43 -0
  24. package/src/server/routes/settings.ts +42 -8
  25. package/src/server/ws/chat.ts +2 -2
  26. package/src/services/config.service.ts +5 -1
  27. package/src/services/project.service.ts +1 -0
  28. package/src/types/config.ts +37 -0
  29. package/src/types/project.ts +1 -0
  30. package/src/web/Users/hienlh/Projects/ppm/dist/web/monacoeditorwork/css.worker.bundle.js +54268 -0
  31. package/src/web/Users/hienlh/Projects/ppm/dist/web/monacoeditorwork/editor.worker.bundle.js +14316 -0
  32. package/src/web/Users/hienlh/Projects/ppm/dist/web/monacoeditorwork/html.worker.bundle.js +30452 -0
  33. package/src/web/Users/hienlh/Projects/ppm/dist/web/monacoeditorwork/json.worker.bundle.js +22095 -0
  34. package/src/web/Users/hienlh/Projects/ppm/dist/web/monacoeditorwork/ts.worker.bundle.js +225957 -0
  35. package/src/web/app.tsx +43 -5
  36. package/src/web/components/chat/chat-history-panel.tsx +106 -0
  37. package/src/web/components/chat/chat-tab.tsx +27 -19
  38. package/src/web/components/editor/code-editor.tsx +78 -197
  39. package/src/web/components/editor/diff-viewer.tsx +59 -176
  40. package/src/web/components/layout/add-project-form.tsx +151 -0
  41. package/src/web/components/layout/command-palette.tsx +3 -1
  42. package/src/web/components/layout/editor-panel.tsx +6 -4
  43. package/src/web/components/layout/mobile-drawer.tsx +48 -180
  44. package/src/web/components/layout/mobile-nav.tsx +89 -6
  45. package/src/web/components/layout/panel-layout.tsx +16 -10
  46. package/src/web/components/layout/project-bar.tsx +329 -0
  47. package/src/web/components/layout/project-bottom-sheet.tsx +345 -0
  48. package/src/web/components/layout/sidebar.tsx +56 -142
  49. package/src/web/components/layout/tab-bar.tsx +1 -6
  50. package/src/web/components/layout/tab-content.tsx +0 -10
  51. package/src/web/components/ui/dialog.tsx +1 -1
  52. package/src/web/lib/project-avatar.ts +45 -0
  53. package/src/web/lib/project-palette.ts +18 -0
  54. package/src/web/lib/use-monaco-theme.ts +29 -0
  55. package/src/web/stores/panel-store.ts +96 -9
  56. package/src/web/stores/project-store.ts +87 -3
  57. package/src/web/stores/settings-store.ts +31 -4
  58. package/src/web/stores/tab-store.ts +0 -2
  59. package/vite.config.ts +6 -2
  60. package/dist/web/assets/arrow-up-from-line-DjfWTP75.js +0 -1
  61. package/dist/web/assets/button-CQ5h5gxS.js +0 -41
  62. package/dist/web/assets/chat-tab-Cfw__7vJ.js +0 -6
  63. package/dist/web/assets/code-editor-D8Pz69sx.js +0 -2
  64. package/dist/web/assets/dialog-BL9i7XEo.js +0 -5
  65. package/dist/web/assets/diff-viewer-CWS5n7ur.js +0 -4
  66. package/dist/web/assets/dist-0XHv8Vwc.js +0 -1
  67. package/dist/web/assets/dist-Ca3N8Xbh.js +0 -46
  68. package/dist/web/assets/git-graph-DwA62J8-.js +0 -1
  69. package/dist/web/assets/git-status-panel-DaB-zzSF.js +0 -1
  70. package/dist/web/assets/index-BYIXPY6U.css +0 -2
  71. package/dist/web/assets/index-DbTCLiox.js +0 -17
  72. package/dist/web/assets/project-list-Z4lhtp6P.js +0 -1
  73. package/dist/web/assets/refresh-cw-S6I91MHO.js +0 -1
  74. package/dist/web/assets/settings-tab-BW6MGcir.js +0 -1
  75. package/dist/web/assets/trash-2-CGlFXde_.js +0 -1
  76. package/dist/web/assets/x-C0Rw5Giw.js +0 -1
  77. /package/dist/web/assets/{api-client-DzH9zCD7.js → api-client-BCjah751.js} +0 -0
  78. /package/dist/web/assets/{columns-2-DsiY76NQ.js → columns-2-DFQ3yid7.js} +0 -0
  79. /package/dist/web/assets/{copy-D_Q54D-v.js → copy-B-kLwqzg.js} +0 -0
  80. /package/dist/web/assets/{external-link-C6Y-D528.js → external-link-Dim3NH6h.js} +0 -0
  81. /package/dist/web/assets/{marked.esm-Cv8mjgnt.js → marked.esm-DhBtkBa8.js} +0 -0
  82. /package/dist/web/assets/{utils-D6me7KDg.js → utils-B-_GCz7E.js} +0 -0
@@ -1,43 +1,22 @@
1
- import { useEffect, useState, useMemo, useRef } from "react";
2
- import { oneDark } from "@codemirror/theme-one-dark";
3
- import { EditorView, lineNumbers } from "@codemirror/view";
4
- import { EditorState } from "@codemirror/state";
5
- import { MergeView } from "@codemirror/merge";
6
- import { javascript } from "@codemirror/lang-javascript";
7
- import { python } from "@codemirror/lang-python";
8
- import { html } from "@codemirror/lang-html";
9
- import { css } from "@codemirror/lang-css";
10
- import { json } from "@codemirror/lang-json";
11
- import { markdown } from "@codemirror/lang-markdown";
12
- import type { Extension } from "@codemirror/state";
1
+ import { useEffect, useState, useMemo } from "react";
2
+ import { DiffEditor } from "@monaco-editor/react";
13
3
  import { api, projectUrl } from "@/lib/api-client";
14
4
  import { useSettingsStore } from "@/stores/settings-store";
5
+ import { useMonacoTheme } from "@/lib/use-monaco-theme";
15
6
  import { Loader2, FileCode, PanelLeftOpen, PanelRightOpen, Columns2, WrapText } from "lucide-react";
16
7
 
17
- function getLanguageExtension(filename: string): Extension | null {
8
+ function getMonacoLanguage(filename: string): string {
18
9
  const ext = filename.split(".").pop()?.toLowerCase() ?? "";
19
- switch (ext) {
20
- case "js":
21
- case "jsx":
22
- return javascript({ jsx: true });
23
- case "ts":
24
- case "tsx":
25
- return javascript({ jsx: true, typescript: true });
26
- case "py":
27
- return python();
28
- case "html":
29
- return html();
30
- case "css":
31
- case "scss":
32
- return css();
33
- case "json":
34
- return json();
35
- case "md":
36
- case "mdx":
37
- return markdown();
38
- default:
39
- return null;
40
- }
10
+ const map: Record<string, string> = {
11
+ js: "javascript", jsx: "javascript",
12
+ ts: "typescript", tsx: "typescript",
13
+ py: "python", html: "html",
14
+ css: "css", scss: "scss",
15
+ json: "json", md: "markdown", mdx: "markdown",
16
+ yaml: "yaml", yml: "yaml",
17
+ sh: "shell", bash: "shell",
18
+ };
19
+ return map[ext] ?? "plaintext";
41
20
  }
42
21
 
43
22
  interface DiffViewerProps {
@@ -60,25 +39,21 @@ export function DiffViewer({ metadata }: DiffViewerProps) {
60
39
  const [fileContents, setFileContents] = useState<{ original: string; modified: string } | null>(null);
61
40
  const [loading, setLoading] = useState(!isInline);
62
41
  const [error, setError] = useState<string | null>(null);
63
- /** "both" | "left" | "right" — controls which diff panel is expanded */
64
42
  const [expandMode, setExpandMode] = useState<"both" | "left" | "right">("both");
65
43
  const { wordWrap, toggleWordWrap } = useSettingsStore();
66
- const containerRef = useRef<HTMLDivElement>(null);
67
- const mergeViewRef = useRef<MergeView | null>(null);
44
+ const monacoTheme = useMonacoTheme();
68
45
 
69
46
  useEffect(() => {
70
- if (isInline) return; // No fetch needed for inline diffs
47
+ if (isInline) return;
71
48
  if (!projectName) return;
72
49
  setLoading(true);
73
50
  setError(null);
74
51
 
75
52
  if (file1 && file2) {
76
- const params = new URLSearchParams();
77
- params.set("file1", file1);
78
- params.set("file2", file2);
53
+ const params = new URLSearchParams({ file1, file2 });
79
54
  api
80
55
  .get<{ original: string; modified: string }>(
81
- `${projectUrl(projectName)}/files/compare?${params.toString()}`,
56
+ `${projectUrl(projectName)}/files/compare?${params}`,
82
57
  )
83
58
  .then((data) => { setFileContents(data); setLoading(false); })
84
59
  .catch((err) => { setError(err instanceof Error ? err.message : "Failed to compare files"); setLoading(false); });
@@ -87,15 +62,14 @@ export function DiffViewer({ metadata }: DiffViewerProps) {
87
62
 
88
63
  let url: string;
89
64
  if (filePath) {
90
- const params = new URLSearchParams();
91
- params.set("file", filePath);
65
+ const params = new URLSearchParams({ file: filePath });
92
66
  if (ref1) params.set("ref", ref1);
93
- url = `${projectUrl(projectName)}/git/file-diff?${params.toString()}`;
67
+ url = `${projectUrl(projectName)}/git/file-diff?${params}`;
94
68
  } else if (ref1 || ref2) {
95
69
  const params = new URLSearchParams();
96
70
  if (ref1) params.set("ref1", ref1);
97
71
  if (ref2) params.set("ref2", ref2);
98
- url = `${projectUrl(projectName)}/git/diff?${params.toString()}`;
72
+ url = `${projectUrl(projectName)}/git/diff?${params}`;
99
73
  } else {
100
74
  url = `${projectUrl(projectName)}/git/diff`;
101
75
  }
@@ -104,7 +78,7 @@ export function DiffViewer({ metadata }: DiffViewerProps) {
104
78
  .get<{ diff: string }>(url)
105
79
  .then((data) => { setDiffText(data.diff); setLoading(false); })
106
80
  .catch((err) => { setError(err instanceof Error ? err.message : "Failed to load diff"); setLoading(false); });
107
- }, [filePath, projectName, ref1, ref2, file1, file2]);
81
+ }, [filePath, projectName, ref1, ref2, file1, file2, isInline]);
108
82
 
109
83
  const { original, modified } = useMemo(() => {
110
84
  if (isInline) return { original: inlineOriginal ?? "", modified: inlineModified ?? "" };
@@ -113,105 +87,11 @@ export function DiffViewer({ metadata }: DiffViewerProps) {
113
87
  return parseDiff(diffText);
114
88
  }, [diffText, isInline, inlineOriginal, inlineModified, isFileCompare, fileContents]);
115
89
 
116
- const langExts = useMemo(() => {
90
+ const language = useMemo(() => {
117
91
  const langFile = filePath ?? file2 ?? file1;
118
- if (!langFile) return [];
119
- const ext = getLanguageExtension(langFile);
120
- return ext ? [ext] : [];
92
+ return langFile ? getMonacoLanguage(langFile) : "plaintext";
121
93
  }, [filePath, file1, file2]);
122
94
 
123
- // Create MergeView when content is ready
124
- useEffect(() => {
125
- const container = containerRef.current;
126
- if (!container || loading || error) return;
127
- if (!original && !modified) return;
128
-
129
- // Clean up previous
130
- if (mergeViewRef.current) {
131
- mergeViewRef.current.destroy();
132
- mergeViewRef.current = null;
133
- }
134
-
135
- const isMobile = window.innerWidth < 768;
136
- const sharedExts: Extension[] = [
137
- ...langExts,
138
- oneDark,
139
- EditorView.editable.of(false),
140
- EditorState.readOnly.of(true),
141
- lineNumbers(),
142
- ...(wordWrap ? [EditorView.lineWrapping] : []),
143
- EditorView.theme({
144
- "&": { fontSize: "13px", fontFamily: "var(--font-mono)" },
145
- // Character-level highlight: bold background, NO underline
146
- "& .cm-changedText": {
147
- textDecoration: "none !important",
148
- borderBottom: "none !important",
149
- textDecorationLine: "none !important",
150
- backgroundColor: "rgba(16, 185, 129, 0.4) !important",
151
- borderRadius: "2px",
152
- },
153
- "& .cm-deletedChunk .cm-changedText": {
154
- backgroundColor: "rgba(239, 68, 68, 0.4) !important",
155
- },
156
- }),
157
- ];
158
-
159
- const mv = new MergeView({
160
- parent: container,
161
- a: { doc: original, extensions: sharedExts },
162
- b: { doc: modified, extensions: sharedExts },
163
- orientation: "a-b",
164
- revertControls: undefined,
165
- highlightChanges: true, // Highlight changed characters within a line
166
- gutter: true,
167
- });
168
-
169
- mergeViewRef.current = mv;
170
-
171
- // Sync horizontal scroll between both editors
172
- const scrollerA = mv.a.dom.querySelector(".cm-scroller") as HTMLElement | null;
173
- const scrollerB = mv.b.dom.querySelector(".cm-scroller") as HTMLElement | null;
174
- let syncing = false;
175
- const syncScroll = (source: HTMLElement, target: HTMLElement) => {
176
- if (syncing) return;
177
- syncing = true;
178
- target.scrollLeft = source.scrollLeft;
179
- syncing = false;
180
- };
181
- const onScrollA = () => scrollerA && scrollerB && syncScroll(scrollerA, scrollerB);
182
- const onScrollB = () => scrollerA && scrollerB && syncScroll(scrollerB, scrollerA);
183
- scrollerA?.addEventListener("scroll", onScrollA);
184
- scrollerB?.addEventListener("scroll", onScrollB);
185
-
186
- return () => {
187
- scrollerA?.removeEventListener("scroll", onScrollA);
188
- scrollerB?.removeEventListener("scroll", onScrollB);
189
- mv.destroy();
190
- mergeViewRef.current = null;
191
- };
192
- }, [original, modified, langExts, loading, error, wordWrap]);
193
-
194
- // Apply expand mode by hiding left/right editor panels via CSS
195
- useEffect(() => {
196
- const mv = mergeViewRef.current;
197
- if (!mv) return;
198
- // MergeView exposes .a (left) and .b (right) EditorView instances
199
- const leftPanel = mv.a.dom.closest(".cm-mergeViewEditor") as HTMLElement | null ?? mv.a.dom.parentElement;
200
- const rightPanel = mv.b.dom.closest(".cm-mergeViewEditor") as HTMLElement | null ?? mv.b.dom.parentElement;
201
- const container = containerRef.current;
202
- const gutter = container?.querySelector<HTMLElement>(".cm-mergeViewGutter");
203
-
204
- if (leftPanel) {
205
- leftPanel.style.display = expandMode === "right" ? "none" : "";
206
- leftPanel.style.flex = expandMode === "left" ? "1" : "";
207
- }
208
- if (rightPanel) {
209
- rightPanel.style.display = expandMode === "left" ? "none" : "";
210
- rightPanel.style.flex = expandMode === "right" ? "1" : "";
211
- }
212
- if (gutter) gutter.style.display = expandMode !== "both" ? "none" : "";
213
- }, [expandMode, original, modified]);
214
-
215
95
  if (!projectName && !isInline) {
216
96
  return (
217
97
  <div className="flex items-center justify-center h-full text-muted-foreground text-sm">
@@ -231,9 +111,7 @@ export function DiffViewer({ metadata }: DiffViewerProps) {
231
111
 
232
112
  if (error) {
233
113
  return (
234
- <div className="flex items-center justify-center h-full text-destructive text-sm">
235
- {error}
236
- </div>
114
+ <div className="flex items-center justify-center h-full text-destructive text-sm">{error}</div>
237
115
  );
238
116
  }
239
117
 
@@ -247,26 +125,26 @@ export function DiffViewer({ metadata }: DiffViewerProps) {
247
125
  );
248
126
  }
249
127
 
128
+ // expandMode left/right → inline diff (Monaco has no single-side mode)
129
+ const renderSideBySide = expandMode === "both";
130
+
250
131
  const expandToggle = (
251
132
  <div className="flex items-center gap-0.5 shrink-0">
252
- <button
253
- type="button"
133
+ <button type="button"
254
134
  onClick={() => setExpandMode(expandMode === "left" ? "both" : "left")}
255
135
  className={`p-1 rounded hover:bg-muted transition-colors ${expandMode === "left" ? "bg-muted text-foreground" : ""}`}
256
136
  title="Expand original"
257
137
  >
258
138
  <PanelLeftOpen className="size-3.5" />
259
139
  </button>
260
- <button
261
- type="button"
140
+ <button type="button"
262
141
  onClick={() => setExpandMode("both")}
263
142
  className={`p-1 rounded hover:bg-muted transition-colors ${expandMode === "both" ? "bg-muted text-foreground" : ""}`}
264
143
  title="Side by side"
265
144
  >
266
145
  <Columns2 className="size-3.5" />
267
146
  </button>
268
- <button
269
- type="button"
147
+ <button type="button"
270
148
  onClick={() => setExpandMode(expandMode === "right" ? "both" : "right")}
271
149
  className={`p-1 rounded hover:bg-muted transition-colors ${expandMode === "right" ? "bg-muted text-foreground" : ""}`}
272
150
  title="Expand modified"
@@ -274,10 +152,7 @@ export function DiffViewer({ metadata }: DiffViewerProps) {
274
152
  <PanelRightOpen className="size-3.5" />
275
153
  </button>
276
154
  <div className="w-px h-3.5 bg-border mx-0.5 shrink-0" />
277
- <button
278
- type="button"
279
- onClick={toggleWordWrap}
280
- title="Toggle word wrap"
155
+ <button type="button" onClick={toggleWordWrap} title="Toggle word wrap"
281
156
  className={`p-1 rounded hover:bg-muted transition-colors ${wordWrap ? "bg-muted text-foreground" : ""}`}
282
157
  >
283
158
  <WrapText className="size-3.5" />
@@ -300,18 +175,31 @@ export function DiffViewer({ metadata }: DiffViewerProps) {
300
175
  )}
301
176
  </span>
302
177
  )}
303
- {/* Desktop: expand toggle in header */}
304
178
  <div className="hidden md:block">{expandToggle}</div>
305
179
  </div>
306
180
 
307
- {/* MergeView container — side-by-side, pinch-zoom on mobile */}
308
- <div
309
- ref={containerRef}
310
- className="flex-1 overflow-auto touch-pinch-zoom [&_.cm-mergeView]:h-full"
311
- style={{ WebkitOverflowScrolling: "touch" }}
312
- />
181
+ {/* Monaco DiffEditor */}
182
+ <div className="flex-1 overflow-hidden">
183
+ <DiffEditor
184
+ height="100%"
185
+ language={language}
186
+ original={original}
187
+ modified={modified}
188
+ theme={monacoTheme}
189
+ options={{
190
+ fontSize: 13,
191
+ fontFamily: "Menlo, Monaco, Consolas, monospace",
192
+ wordWrap: wordWrap ? "on" : "off",
193
+ renderSideBySide,
194
+ readOnly: true,
195
+ automaticLayout: true,
196
+ scrollBeyondLastLine: false,
197
+ }}
198
+ loading={<Loader2 className="size-5 animate-spin text-text-subtle" />}
199
+ />
200
+ </div>
313
201
 
314
- {/* Mobile: expand toggle at bottom */}
202
+ {/* Mobile expand toggle */}
315
203
  <div className="md:hidden flex justify-center border-t py-1 bg-background shrink-0">
316
204
  {expandToggle}
317
205
  </div>
@@ -327,16 +215,11 @@ function parseDiff(diff: string): { original: string; modified: string } {
327
215
 
328
216
  for (const line of lines) {
329
217
  if (
330
- line.startsWith("diff --git") ||
331
- line.startsWith("diff --no-index") ||
332
- line.startsWith("index ") ||
333
- line.startsWith("new file") ||
334
- line.startsWith("deleted file") ||
335
- line.startsWith("old mode") ||
336
- line.startsWith("new mode") ||
337
- line.startsWith("---") ||
338
- line.startsWith("+++") ||
339
- line.startsWith("Binary files") ||
218
+ line.startsWith("diff --git") || line.startsWith("diff --no-index") ||
219
+ line.startsWith("index ") || line.startsWith("new file") ||
220
+ line.startsWith("deleted file") || line.startsWith("old mode") ||
221
+ line.startsWith("new mode") || line.startsWith("---") ||
222
+ line.startsWith("+++") || line.startsWith("Binary files") ||
340
223
  line.startsWith("\\ No newline")
341
224
  ) continue;
342
225
 
@@ -347,7 +230,7 @@ function parseDiff(diff: string): { original: string; modified: string } {
347
230
  originalLines.push(line.slice(1));
348
231
  } else if (line.startsWith("+")) {
349
232
  modifiedLines.push(line.slice(1));
350
- } else if (line.startsWith(" ") || line === "") {
233
+ } else {
351
234
  const content = line.startsWith(" ") ? line.slice(1) : line;
352
235
  originalLines.push(content);
353
236
  modifiedLines.push(content);
@@ -0,0 +1,151 @@
1
+ import { useState, useEffect, useRef } from "react";
2
+ import { Loader2, FolderOpen } from "lucide-react";
3
+ import { useProjectStore } from "@/stores/project-store";
4
+ import { api } from "@/lib/api-client";
5
+ import { cn } from "@/lib/utils";
6
+
7
+ interface SuggestedDir {
8
+ path: string;
9
+ name: string;
10
+ }
11
+
12
+ interface AddProjectFormProps {
13
+ onSuccess: () => void;
14
+ onCancel: () => void;
15
+ /** Extra class for the submit button row */
16
+ footerClassName?: string;
17
+ }
18
+
19
+ export function AddProjectForm({ onSuccess, onCancel, footerClassName }: AddProjectFormProps) {
20
+ const { addProject } = useProjectStore();
21
+ const [path, setPath] = useState("");
22
+ const [name, setName] = useState("");
23
+ const [suggestions, setSuggestions] = useState<SuggestedDir[]>([]);
24
+ const [showSuggestions, setShowSuggestions] = useState(false);
25
+ const [loading, setLoading] = useState(false);
26
+ const [submitting, setSubmitting] = useState(false);
27
+ const [error, setError] = useState("");
28
+ const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
29
+ const wrapperRef = useRef<HTMLDivElement>(null);
30
+
31
+ // Fetch suggestions when path changes
32
+ useEffect(() => {
33
+ if (debounceRef.current) clearTimeout(debounceRef.current);
34
+ if (!path.trim()) { setSuggestions([]); setShowSuggestions(false); return; }
35
+ debounceRef.current = setTimeout(async () => {
36
+ setLoading(true);
37
+ try {
38
+ const results = await api.get<SuggestedDir[]>(`/api/projects/suggest-dirs?q=${encodeURIComponent(path)}`);
39
+ setSuggestions(results ?? []);
40
+ setShowSuggestions(true);
41
+ } catch {
42
+ setSuggestions([]);
43
+ } finally {
44
+ setLoading(false);
45
+ }
46
+ }, 250);
47
+ }, [path]);
48
+
49
+ // Close suggestions on outside click
50
+ useEffect(() => {
51
+ function handle(e: MouseEvent) {
52
+ if (wrapperRef.current && !wrapperRef.current.contains(e.target as Node)) {
53
+ setShowSuggestions(false);
54
+ }
55
+ }
56
+ document.addEventListener("mousedown", handle);
57
+ return () => document.removeEventListener("mousedown", handle);
58
+ }, []);
59
+
60
+ function selectSuggestion(dir: SuggestedDir) {
61
+ setPath(dir.path);
62
+ if (!name) setName(dir.name);
63
+ setShowSuggestions(false);
64
+ }
65
+
66
+ async function handleSubmit(e?: React.FormEvent) {
67
+ e?.preventDefault();
68
+ if (!path.trim()) { setError("Path is required"); return; }
69
+ setError("");
70
+ setSubmitting(true);
71
+ try {
72
+ await addProject(path.trim(), name.trim() || undefined);
73
+ onSuccess();
74
+ } catch (err) {
75
+ setError(err instanceof Error ? err.message : "Failed to add project");
76
+ } finally {
77
+ setSubmitting(false);
78
+ }
79
+ }
80
+
81
+ return (
82
+ <form onSubmit={handleSubmit} className="flex flex-col gap-3">
83
+ {/* Path input with suggestions */}
84
+ <div ref={wrapperRef} className="relative">
85
+ <label className="block text-xs font-medium text-foreground mb-1">Project path</label>
86
+ <div className="relative flex items-center">
87
+ <FolderOpen className="absolute left-2.5 size-3.5 text-text-subtle pointer-events-none" />
88
+ <input
89
+ type="text"
90
+ value={path}
91
+ onChange={(e) => { setPath(e.target.value); setError(""); }}
92
+ onFocus={() => suggestions.length > 0 && setShowSuggestions(true)}
93
+ placeholder="/path/to/project"
94
+ className="w-full pl-8 pr-3 py-2 rounded-md border border-border bg-background text-sm focus:outline-none focus:ring-1 focus:ring-primary"
95
+ autoFocus
96
+ autoComplete="off"
97
+ />
98
+ {loading && <Loader2 className="absolute right-2.5 size-3.5 text-text-subtle animate-spin" />}
99
+ </div>
100
+
101
+ {/* Suggestions dropdown */}
102
+ {showSuggestions && suggestions.length > 0 && (
103
+ <div className="absolute top-full left-0 right-0 mt-1 z-10 bg-popover border border-border rounded-md shadow-md max-h-48 overflow-y-auto">
104
+ {suggestions.map((dir) => (
105
+ <button
106
+ key={dir.path}
107
+ type="button"
108
+ onMouseDown={() => selectSuggestion(dir)}
109
+ className="w-full flex flex-col items-start px-3 py-2 text-left hover:bg-accent/50 transition-colors"
110
+ >
111
+ <span className="text-sm font-medium truncate w-full">{dir.name}</span>
112
+ <span className="text-xs text-text-subtle truncate w-full">{dir.path}</span>
113
+ </button>
114
+ ))}
115
+ </div>
116
+ )}
117
+ </div>
118
+
119
+ {/* Optional name */}
120
+ <div>
121
+ <label className="block text-xs font-medium text-foreground mb-1">Display name <span className="text-muted-foreground">(optional)</span></label>
122
+ <input
123
+ type="text"
124
+ value={name}
125
+ onChange={(e) => setName(e.target.value)}
126
+ placeholder="my-project"
127
+ className="w-full px-3 py-2 rounded-md border border-border bg-background text-sm focus:outline-none focus:ring-1 focus:ring-primary"
128
+ />
129
+ </div>
130
+
131
+ {error && <p className="text-xs text-destructive">{error}</p>}
132
+
133
+ <div className={cn("flex justify-end gap-2 pt-1", footerClassName)}>
134
+ <button
135
+ type="button"
136
+ onClick={onCancel}
137
+ className="px-3 py-1.5 text-sm text-text-secondary hover:text-foreground transition-colors"
138
+ >
139
+ Cancel
140
+ </button>
141
+ <button
142
+ type="submit"
143
+ disabled={submitting || !path.trim()}
144
+ className="px-3 py-1.5 text-sm bg-primary text-white rounded-md hover:bg-primary/90 transition-colors disabled:opacity-50"
145
+ >
146
+ {submitting ? "Adding…" : "Add Project"}
147
+ </button>
148
+ </div>
149
+ </form>
150
+ );
151
+ }
@@ -10,6 +10,7 @@ import {
10
10
  } from "lucide-react";
11
11
  import { useTabStore, type TabType } from "@/stores/tab-store";
12
12
  import { useProjectStore } from "@/stores/project-store";
13
+ import { useSettingsStore } from "@/stores/settings-store";
13
14
  import { useFileStore, type FileNode } from "@/stores/file-store";
14
15
 
15
16
  interface CommandItem {
@@ -45,6 +46,7 @@ export function CommandPalette({ open, onClose }: { open: boolean; onClose: () =
45
46
  const openTab = useTabStore((s) => s.openTab);
46
47
  const activeProject = useProjectStore((s) => s.activeProject);
47
48
  const fileTree = useFileStore((s) => s.tree);
49
+ const setSidebarActiveTab = useSettingsStore((s) => s.setSidebarActiveTab);
48
50
 
49
51
  // Action commands
50
52
  const actionCommands = useMemo<CommandItem[]>(() => {
@@ -60,7 +62,7 @@ export function CommandPalette({ open, onClose }: { open: boolean; onClose: () =
60
62
  { id: "terminal", label: "New Terminal", icon: Terminal, action: openNewTab("terminal", "Terminal"), keywords: "bash shell console", group: "action" },
61
63
  { id: "chat", label: "New AI Chat", icon: MessageSquare, action: openNewTab("chat", "AI Chat"), keywords: "ai assistant claude", group: "action" },
62
64
  { id: "git-graph", label: "Git Graph", icon: GitBranch, action: openNewTab("git-graph", "Git Graph"), keywords: "branch history log", group: "action" },
63
- { id: "git-status", label: "Git Status", icon: GitCommitHorizontal, action: openNewTab("git-status", "Git Status"), keywords: "changes diff staged", group: "action" },
65
+ { id: "git-status", label: "Git Status", icon: GitCommitHorizontal, action: () => { setSidebarActiveTab("git"); onClose(); }, keywords: "changes diff staged", group: "action" },
64
66
  { id: "settings", label: "Settings", icon: Settings, action: openNewTab("settings", "Settings"), keywords: "config preferences", group: "action" },
65
67
  ];
66
68
  }, [activeProject, openTab, onClose]);
@@ -7,24 +7,26 @@ import { SplitDropOverlay } from "./split-drop-overlay";
7
7
  import { cn } from "@/lib/utils";
8
8
 
9
9
  const TAB_COMPONENTS: Record<TabType, React.LazyExoticComponent<React.ComponentType<{ metadata?: Record<string, unknown>; tabId?: string }>>> = {
10
- projects: lazy(() => import("@/components/projects/project-list").then((m) => ({ default: m.ProjectList }))),
11
10
  terminal: lazy(() => import("@/components/terminal/terminal-tab").then((m) => ({ default: m.TerminalTab }))),
12
11
  chat: lazy(() => import("@/components/chat/chat-tab").then((m) => ({ default: m.ChatTab }))),
13
12
  editor: lazy(() => import("@/components/editor/code-editor").then((m) => ({ default: m.CodeEditor }))),
14
13
  "git-graph": lazy(() => import("@/components/git/git-graph").then((m) => ({ default: m.GitGraph }))),
15
- "git-status": lazy(() => import("@/components/git/git-status-panel").then((m) => ({ default: m.GitStatusPanel }))),
16
14
  "git-diff": lazy(() => import("@/components/editor/diff-viewer").then((m) => ({ default: m.DiffViewer }))),
17
15
  settings: lazy(() => import("@/components/settings/settings-tab").then((m) => ({ default: m.SettingsTab }))),
18
16
  };
19
17
 
20
18
  interface EditorPanelProps {
21
19
  panelId: string;
20
+ projectName: string;
22
21
  }
23
22
 
24
- export function EditorPanel({ panelId }: EditorPanelProps) {
23
+ export function EditorPanel({ panelId, projectName }: EditorPanelProps) {
25
24
  const panel = usePanelStore((s) => s.panels[panelId]);
26
25
  const isFocused = usePanelStore((s) => s.focusedPanelId === panelId);
27
- const panelCount = usePanelStore((s) => Object.keys(s.panels).length);
26
+ const panelCount = usePanelStore((s) => {
27
+ const grid = s.currentProject === projectName ? s.grid : (s.projectGrids[projectName] ?? [[]]);
28
+ return grid.flat().length;
29
+ });
28
30
 
29
31
  if (!panel) return null;
30
32