@hienlh/ppm 0.2.19 → 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 (85) 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/api-client-BCjah751.js +1 -0
  6. package/dist/web/assets/chat-tab-C_U7EwM9.js +6 -0
  7. package/dist/web/assets/code-editor-DuarTBEe.js +1 -0
  8. package/dist/web/assets/diff-viewer-sBWBgb7U.js +4 -0
  9. package/dist/web/assets/git-graph-fOKEZiot.js +1 -0
  10. package/dist/web/assets/index-3zt5mBwZ.css +2 -0
  11. package/dist/web/assets/index-CaUQy3Zs.js +21 -0
  12. package/dist/web/assets/input-CTnwfHVN.js +41 -0
  13. package/dist/web/assets/settings-tab-C5aWMqIA.js +1 -0
  14. package/dist/web/assets/{terminal-tab-DlRo-KzS.js → terminal-tab-BEFAYT4S.js} +1 -1
  15. package/dist/web/assets/use-monaco-theme-BxaccPmI.js +11 -0
  16. package/dist/web/index.html +35 -9
  17. package/dist/web/sw.js +1 -1
  18. package/docs/codebase-summary.md +13 -8
  19. package/docs/project-roadmap.md +22 -4
  20. package/docs/system-architecture.md +59 -0
  21. package/package.json +6 -14
  22. package/src/providers/claude-agent-sdk.ts +2 -2
  23. package/src/providers/registry.ts +12 -11
  24. package/src/server/routes/projects.ts +43 -0
  25. package/src/server/routes/settings.ts +42 -8
  26. package/src/server/ws/chat.ts +2 -2
  27. package/src/services/config.service.ts +5 -1
  28. package/src/services/project.service.ts +1 -0
  29. package/src/types/config.ts +37 -0
  30. package/src/types/project.ts +1 -0
  31. package/src/web/Users/hienlh/Projects/ppm/dist/web/monacoeditorwork/css.worker.bundle.js +54268 -0
  32. package/src/web/Users/hienlh/Projects/ppm/dist/web/monacoeditorwork/editor.worker.bundle.js +14316 -0
  33. package/src/web/Users/hienlh/Projects/ppm/dist/web/monacoeditorwork/html.worker.bundle.js +30452 -0
  34. package/src/web/Users/hienlh/Projects/ppm/dist/web/monacoeditorwork/json.worker.bundle.js +22095 -0
  35. package/src/web/Users/hienlh/Projects/ppm/dist/web/monacoeditorwork/ts.worker.bundle.js +225957 -0
  36. package/src/web/app.tsx +43 -5
  37. package/src/web/components/chat/chat-history-panel.tsx +106 -0
  38. package/src/web/components/chat/chat-tab.tsx +27 -19
  39. package/src/web/components/editor/code-editor.tsx +101 -173
  40. package/src/web/components/editor/diff-viewer.tsx +67 -172
  41. package/src/web/components/git/git-status-panel.tsx +4 -11
  42. package/src/web/components/layout/add-project-form.tsx +151 -0
  43. package/src/web/components/layout/command-palette.tsx +3 -1
  44. package/src/web/components/layout/editor-panel.tsx +6 -4
  45. package/src/web/components/layout/mobile-drawer.tsx +48 -180
  46. package/src/web/components/layout/mobile-nav.tsx +89 -6
  47. package/src/web/components/layout/panel-layout.tsx +16 -10
  48. package/src/web/components/layout/project-bar.tsx +329 -0
  49. package/src/web/components/layout/project-bottom-sheet.tsx +345 -0
  50. package/src/web/components/layout/sidebar.tsx +56 -142
  51. package/src/web/components/layout/tab-bar.tsx +1 -6
  52. package/src/web/components/layout/tab-content.tsx +0 -10
  53. package/src/web/components/ui/dialog.tsx +1 -1
  54. package/src/web/lib/project-avatar.ts +45 -0
  55. package/src/web/lib/project-palette.ts +18 -0
  56. package/src/web/lib/use-monaco-theme.ts +29 -0
  57. package/src/web/stores/panel-store.ts +96 -9
  58. package/src/web/stores/project-store.ts +87 -3
  59. package/src/web/stores/settings-store.ts +52 -5
  60. package/src/web/stores/tab-store.ts +0 -2
  61. package/vite.config.ts +6 -2
  62. package/dist/web/assets/api-client-B_eCZViO.js +0 -1
  63. package/dist/web/assets/arrow-up-from-line-DjfWTP75.js +0 -1
  64. package/dist/web/assets/button-CvHWF07y.js +0 -41
  65. package/dist/web/assets/chat-tab-DJvME48K.js +0 -6
  66. package/dist/web/assets/code-editor-81Tzd5aV.js +0 -2
  67. package/dist/web/assets/dialog-Cn5zGuid.js +0 -5
  68. package/dist/web/assets/diff-viewer-pieRctzs.js +0 -4
  69. package/dist/web/assets/dist-B6sG2GPc.js +0 -1
  70. package/dist/web/assets/dist-CBiGQxfr.js +0 -46
  71. package/dist/web/assets/git-graph-CWI6hxtE.js +0 -1
  72. package/dist/web/assets/git-status-panel-CAjReViM.js +0 -1
  73. package/dist/web/assets/index-BdUoflYx.css +0 -2
  74. package/dist/web/assets/index-CqpLusQd.js +0 -17
  75. package/dist/web/assets/project-list-MAvAY2K3.js +0 -1
  76. package/dist/web/assets/react-C32bf_ch.js +0 -1
  77. package/dist/web/assets/refresh-cw-S6I91MHO.js +0 -1
  78. package/dist/web/assets/settings-tab-zeZrAFld.js +0 -1
  79. package/dist/web/assets/trash-2-Dc17nbCE.js +0 -1
  80. package/dist/web/assets/x-Bpqyw40Y.js +0 -1
  81. /package/dist/web/assets/{columns-2-DsiY76NQ.js → columns-2-DFQ3yid7.js} +0 -0
  82. /package/dist/web/assets/{copy-D_Q54D-v.js → copy-B-kLwqzg.js} +0 -0
  83. /package/dist/web/assets/{external-link-C6Y-D528.js → external-link-Dim3NH6h.js} +0 -0
  84. /package/dist/web/assets/{marked.esm-Cv8mjgnt.js → marked.esm-DhBtkBa8.js} +0 -0
  85. /package/dist/web/assets/{utils-61GRB9Cb.js → utils-B-_GCz7E.js} +0 -0
@@ -1,42 +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
- import { Loader2, FileCode, PanelLeftOpen, PanelRightOpen, Columns2 } from "lucide-react";
4
+ import { useSettingsStore } from "@/stores/settings-store";
5
+ import { useMonacoTheme } from "@/lib/use-monaco-theme";
6
+ import { Loader2, FileCode, PanelLeftOpen, PanelRightOpen, Columns2, WrapText } from "lucide-react";
15
7
 
16
- function getLanguageExtension(filename: string): Extension | null {
8
+ function getMonacoLanguage(filename: string): string {
17
9
  const ext = filename.split(".").pop()?.toLowerCase() ?? "";
18
- switch (ext) {
19
- case "js":
20
- case "jsx":
21
- return javascript({ jsx: true });
22
- case "ts":
23
- case "tsx":
24
- return javascript({ jsx: true, typescript: true });
25
- case "py":
26
- return python();
27
- case "html":
28
- return html();
29
- case "css":
30
- case "scss":
31
- return css();
32
- case "json":
33
- return json();
34
- case "md":
35
- case "mdx":
36
- return markdown();
37
- default:
38
- return null;
39
- }
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";
40
20
  }
41
21
 
42
22
  interface DiffViewerProps {
@@ -59,24 +39,21 @@ export function DiffViewer({ metadata }: DiffViewerProps) {
59
39
  const [fileContents, setFileContents] = useState<{ original: string; modified: string } | null>(null);
60
40
  const [loading, setLoading] = useState(!isInline);
61
41
  const [error, setError] = useState<string | null>(null);
62
- /** "both" | "left" | "right" — controls which diff panel is expanded */
63
42
  const [expandMode, setExpandMode] = useState<"both" | "left" | "right">("both");
64
- const containerRef = useRef<HTMLDivElement>(null);
65
- const mergeViewRef = useRef<MergeView | null>(null);
43
+ const { wordWrap, toggleWordWrap } = useSettingsStore();
44
+ const monacoTheme = useMonacoTheme();
66
45
 
67
46
  useEffect(() => {
68
- if (isInline) return; // No fetch needed for inline diffs
47
+ if (isInline) return;
69
48
  if (!projectName) return;
70
49
  setLoading(true);
71
50
  setError(null);
72
51
 
73
52
  if (file1 && file2) {
74
- const params = new URLSearchParams();
75
- params.set("file1", file1);
76
- params.set("file2", file2);
53
+ const params = new URLSearchParams({ file1, file2 });
77
54
  api
78
55
  .get<{ original: string; modified: string }>(
79
- `${projectUrl(projectName)}/files/compare?${params.toString()}`,
56
+ `${projectUrl(projectName)}/files/compare?${params}`,
80
57
  )
81
58
  .then((data) => { setFileContents(data); setLoading(false); })
82
59
  .catch((err) => { setError(err instanceof Error ? err.message : "Failed to compare files"); setLoading(false); });
@@ -85,15 +62,14 @@ export function DiffViewer({ metadata }: DiffViewerProps) {
85
62
 
86
63
  let url: string;
87
64
  if (filePath) {
88
- const params = new URLSearchParams();
89
- params.set("file", filePath);
65
+ const params = new URLSearchParams({ file: filePath });
90
66
  if (ref1) params.set("ref", ref1);
91
- url = `${projectUrl(projectName)}/git/file-diff?${params.toString()}`;
67
+ url = `${projectUrl(projectName)}/git/file-diff?${params}`;
92
68
  } else if (ref1 || ref2) {
93
69
  const params = new URLSearchParams();
94
70
  if (ref1) params.set("ref1", ref1);
95
71
  if (ref2) params.set("ref2", ref2);
96
- url = `${projectUrl(projectName)}/git/diff?${params.toString()}`;
72
+ url = `${projectUrl(projectName)}/git/diff?${params}`;
97
73
  } else {
98
74
  url = `${projectUrl(projectName)}/git/diff`;
99
75
  }
@@ -102,7 +78,7 @@ export function DiffViewer({ metadata }: DiffViewerProps) {
102
78
  .get<{ diff: string }>(url)
103
79
  .then((data) => { setDiffText(data.diff); setLoading(false); })
104
80
  .catch((err) => { setError(err instanceof Error ? err.message : "Failed to load diff"); setLoading(false); });
105
- }, [filePath, projectName, ref1, ref2, file1, file2]);
81
+ }, [filePath, projectName, ref1, ref2, file1, file2, isInline]);
106
82
 
107
83
  const { original, modified } = useMemo(() => {
108
84
  if (isInline) return { original: inlineOriginal ?? "", modified: inlineModified ?? "" };
@@ -111,104 +87,11 @@ export function DiffViewer({ metadata }: DiffViewerProps) {
111
87
  return parseDiff(diffText);
112
88
  }, [diffText, isInline, inlineOriginal, inlineModified, isFileCompare, fileContents]);
113
89
 
114
- const langExts = useMemo(() => {
90
+ const language = useMemo(() => {
115
91
  const langFile = filePath ?? file2 ?? file1;
116
- if (!langFile) return [];
117
- const ext = getLanguageExtension(langFile);
118
- return ext ? [ext] : [];
92
+ return langFile ? getMonacoLanguage(langFile) : "plaintext";
119
93
  }, [filePath, file1, file2]);
120
94
 
121
- // Create MergeView when content is ready
122
- useEffect(() => {
123
- const container = containerRef.current;
124
- if (!container || loading || error) return;
125
- if (!original && !modified) return;
126
-
127
- // Clean up previous
128
- if (mergeViewRef.current) {
129
- mergeViewRef.current.destroy();
130
- mergeViewRef.current = null;
131
- }
132
-
133
- const isMobile = window.innerWidth < 768;
134
- const sharedExts: Extension[] = [
135
- ...langExts,
136
- oneDark,
137
- EditorView.editable.of(false),
138
- EditorState.readOnly.of(true),
139
- lineNumbers(),
140
- EditorView.theme({
141
- "&": { fontSize: "13px", fontFamily: "var(--font-mono)" },
142
- // Character-level highlight: bold background, NO underline
143
- "& .cm-changedText": {
144
- textDecoration: "none !important",
145
- borderBottom: "none !important",
146
- textDecorationLine: "none !important",
147
- backgroundColor: "rgba(16, 185, 129, 0.4) !important",
148
- borderRadius: "2px",
149
- },
150
- "& .cm-deletedChunk .cm-changedText": {
151
- backgroundColor: "rgba(239, 68, 68, 0.4) !important",
152
- },
153
- }),
154
- ];
155
-
156
- const mv = new MergeView({
157
- parent: container,
158
- a: { doc: original, extensions: sharedExts },
159
- b: { doc: modified, extensions: sharedExts },
160
- orientation: "a-b",
161
- revertControls: undefined,
162
- highlightChanges: true, // Highlight changed characters within a line
163
- gutter: true,
164
- });
165
-
166
- mergeViewRef.current = mv;
167
-
168
- // Sync horizontal scroll between both editors
169
- const scrollerA = mv.a.dom.querySelector(".cm-scroller") as HTMLElement | null;
170
- const scrollerB = mv.b.dom.querySelector(".cm-scroller") as HTMLElement | null;
171
- let syncing = false;
172
- const syncScroll = (source: HTMLElement, target: HTMLElement) => {
173
- if (syncing) return;
174
- syncing = true;
175
- target.scrollLeft = source.scrollLeft;
176
- syncing = false;
177
- };
178
- const onScrollA = () => scrollerA && scrollerB && syncScroll(scrollerA, scrollerB);
179
- const onScrollB = () => scrollerA && scrollerB && syncScroll(scrollerB, scrollerA);
180
- scrollerA?.addEventListener("scroll", onScrollA);
181
- scrollerB?.addEventListener("scroll", onScrollB);
182
-
183
- return () => {
184
- scrollerA?.removeEventListener("scroll", onScrollA);
185
- scrollerB?.removeEventListener("scroll", onScrollB);
186
- mv.destroy();
187
- mergeViewRef.current = null;
188
- };
189
- }, [original, modified, langExts, loading, error]);
190
-
191
- // Apply expand mode by hiding left/right editor panels via CSS
192
- useEffect(() => {
193
- const mv = mergeViewRef.current;
194
- if (!mv) return;
195
- // MergeView exposes .a (left) and .b (right) EditorView instances
196
- const leftPanel = mv.a.dom.closest(".cm-mergeViewEditor") as HTMLElement | null ?? mv.a.dom.parentElement;
197
- const rightPanel = mv.b.dom.closest(".cm-mergeViewEditor") as HTMLElement | null ?? mv.b.dom.parentElement;
198
- const container = containerRef.current;
199
- const gutter = container?.querySelector<HTMLElement>(".cm-mergeViewGutter");
200
-
201
- if (leftPanel) {
202
- leftPanel.style.display = expandMode === "right" ? "none" : "";
203
- leftPanel.style.flex = expandMode === "left" ? "1" : "";
204
- }
205
- if (rightPanel) {
206
- rightPanel.style.display = expandMode === "left" ? "none" : "";
207
- rightPanel.style.flex = expandMode === "right" ? "1" : "";
208
- }
209
- if (gutter) gutter.style.display = expandMode !== "both" ? "none" : "";
210
- }, [expandMode, original, modified]);
211
-
212
95
  if (!projectName && !isInline) {
213
96
  return (
214
97
  <div className="flex items-center justify-center h-full text-muted-foreground text-sm">
@@ -228,9 +111,7 @@ export function DiffViewer({ metadata }: DiffViewerProps) {
228
111
 
229
112
  if (error) {
230
113
  return (
231
- <div className="flex items-center justify-center h-full text-destructive text-sm">
232
- {error}
233
- </div>
114
+ <div className="flex items-center justify-center h-full text-destructive text-sm">{error}</div>
234
115
  );
235
116
  }
236
117
 
@@ -244,32 +125,38 @@ export function DiffViewer({ metadata }: DiffViewerProps) {
244
125
  );
245
126
  }
246
127
 
128
+ // expandMode left/right → inline diff (Monaco has no single-side mode)
129
+ const renderSideBySide = expandMode === "both";
130
+
247
131
  const expandToggle = (
248
132
  <div className="flex items-center gap-0.5 shrink-0">
249
- <button
250
- type="button"
133
+ <button type="button"
251
134
  onClick={() => setExpandMode(expandMode === "left" ? "both" : "left")}
252
135
  className={`p-1 rounded hover:bg-muted transition-colors ${expandMode === "left" ? "bg-muted text-foreground" : ""}`}
253
136
  title="Expand original"
254
137
  >
255
138
  <PanelLeftOpen className="size-3.5" />
256
139
  </button>
257
- <button
258
- type="button"
140
+ <button type="button"
259
141
  onClick={() => setExpandMode("both")}
260
142
  className={`p-1 rounded hover:bg-muted transition-colors ${expandMode === "both" ? "bg-muted text-foreground" : ""}`}
261
143
  title="Side by side"
262
144
  >
263
145
  <Columns2 className="size-3.5" />
264
146
  </button>
265
- <button
266
- type="button"
147
+ <button type="button"
267
148
  onClick={() => setExpandMode(expandMode === "right" ? "both" : "right")}
268
149
  className={`p-1 rounded hover:bg-muted transition-colors ${expandMode === "right" ? "bg-muted text-foreground" : ""}`}
269
150
  title="Expand modified"
270
151
  >
271
152
  <PanelRightOpen className="size-3.5" />
272
153
  </button>
154
+ <div className="w-px h-3.5 bg-border mx-0.5 shrink-0" />
155
+ <button type="button" onClick={toggleWordWrap} title="Toggle word wrap"
156
+ className={`p-1 rounded hover:bg-muted transition-colors ${wordWrap ? "bg-muted text-foreground" : ""}`}
157
+ >
158
+ <WrapText className="size-3.5" />
159
+ </button>
273
160
  </div>
274
161
  );
275
162
 
@@ -288,18 +175,31 @@ export function DiffViewer({ metadata }: DiffViewerProps) {
288
175
  )}
289
176
  </span>
290
177
  )}
291
- {/* Desktop: expand toggle in header */}
292
178
  <div className="hidden md:block">{expandToggle}</div>
293
179
  </div>
294
180
 
295
- {/* MergeView container — side-by-side, pinch-zoom on mobile */}
296
- <div
297
- ref={containerRef}
298
- className="flex-1 overflow-auto touch-pinch-zoom [&_.cm-mergeView]:h-full"
299
- style={{ WebkitOverflowScrolling: "touch" }}
300
- />
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>
301
201
 
302
- {/* Mobile: expand toggle at bottom */}
202
+ {/* Mobile expand toggle */}
303
203
  <div className="md:hidden flex justify-center border-t py-1 bg-background shrink-0">
304
204
  {expandToggle}
305
205
  </div>
@@ -315,16 +215,11 @@ function parseDiff(diff: string): { original: string; modified: string } {
315
215
 
316
216
  for (const line of lines) {
317
217
  if (
318
- line.startsWith("diff --git") ||
319
- line.startsWith("diff --no-index") ||
320
- line.startsWith("index ") ||
321
- line.startsWith("new file") ||
322
- line.startsWith("deleted file") ||
323
- line.startsWith("old mode") ||
324
- line.startsWith("new mode") ||
325
- line.startsWith("---") ||
326
- line.startsWith("+++") ||
327
- 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") ||
328
223
  line.startsWith("\\ No newline")
329
224
  ) continue;
330
225
 
@@ -335,7 +230,7 @@ function parseDiff(diff: string): { original: string; modified: string } {
335
230
  originalLines.push(line.slice(1));
336
231
  } else if (line.startsWith("+")) {
337
232
  modifiedLines.push(line.slice(1));
338
- } else if (line.startsWith(" ") || line === "") {
233
+ } else {
339
234
  const content = line.startsWith(" ") ? line.slice(1) : line;
340
235
  originalLines.push(content);
341
236
  modifiedLines.push(content);
@@ -15,6 +15,7 @@ import {
15
15
  } from "lucide-react";
16
16
  import { api, projectUrl } from "@/lib/api-client";
17
17
  import { useTabStore } from "@/stores/tab-store";
18
+ import { useSettingsStore } from "@/stores/settings-store";
18
19
  import { Button } from "@/components/ui/button";
19
20
  import { ScrollArea } from "@/components/ui/scroll-area";
20
21
  import {
@@ -104,17 +105,9 @@ export function GitStatusPanel({ metadata, tabId }: GitStatusPanelProps) {
104
105
  label: string;
105
106
  files: string[];
106
107
  } | null>(null);
107
- const { openTab, updateTab } = useTabStore();
108
-
109
- // Restore viewMode from tab metadata
110
- const viewMode: ViewMode =
111
- (metadata?.viewMode as ViewMode) === "tree" ? "tree" : "flat";
112
-
113
- const setViewMode = (mode: ViewMode) => {
114
- if (tabId) {
115
- updateTab(tabId, { metadata: { ...metadata, viewMode: mode } });
116
- }
117
- };
108
+ const { openTab } = useTabStore();
109
+ const viewMode = useSettingsStore((s) => s.gitStatusViewMode);
110
+ const setViewMode = useSettingsStore((s) => s.setGitStatusViewMode);
118
111
 
119
112
  const fetchStatus = useCallback(async () => {
120
113
  if (!projectName) return;
@@ -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