@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.
- package/CHANGELOG.md +31 -0
- package/CLAUDE.md +18 -1
- package/bun.lock +57 -59
- package/dist/ppm +0 -0
- package/dist/web/assets/api-client-BCjah751.js +1 -0
- package/dist/web/assets/chat-tab-C_U7EwM9.js +6 -0
- package/dist/web/assets/code-editor-DuarTBEe.js +1 -0
- package/dist/web/assets/diff-viewer-sBWBgb7U.js +4 -0
- package/dist/web/assets/git-graph-fOKEZiot.js +1 -0
- package/dist/web/assets/index-3zt5mBwZ.css +2 -0
- package/dist/web/assets/index-CaUQy3Zs.js +21 -0
- package/dist/web/assets/input-CTnwfHVN.js +41 -0
- package/dist/web/assets/settings-tab-C5aWMqIA.js +1 -0
- package/dist/web/assets/{terminal-tab-DlRo-KzS.js → terminal-tab-BEFAYT4S.js} +1 -1
- package/dist/web/assets/use-monaco-theme-BxaccPmI.js +11 -0
- package/dist/web/index.html +35 -9
- package/dist/web/sw.js +1 -1
- package/docs/codebase-summary.md +13 -8
- package/docs/project-roadmap.md +22 -4
- package/docs/system-architecture.md +59 -0
- package/package.json +6 -14
- package/src/providers/claude-agent-sdk.ts +2 -2
- package/src/providers/registry.ts +12 -11
- package/src/server/routes/projects.ts +43 -0
- package/src/server/routes/settings.ts +42 -8
- package/src/server/ws/chat.ts +2 -2
- package/src/services/config.service.ts +5 -1
- package/src/services/project.service.ts +1 -0
- package/src/types/config.ts +37 -0
- package/src/types/project.ts +1 -0
- package/src/web/Users/hienlh/Projects/ppm/dist/web/monacoeditorwork/css.worker.bundle.js +54268 -0
- package/src/web/Users/hienlh/Projects/ppm/dist/web/monacoeditorwork/editor.worker.bundle.js +14316 -0
- package/src/web/Users/hienlh/Projects/ppm/dist/web/monacoeditorwork/html.worker.bundle.js +30452 -0
- package/src/web/Users/hienlh/Projects/ppm/dist/web/monacoeditorwork/json.worker.bundle.js +22095 -0
- package/src/web/Users/hienlh/Projects/ppm/dist/web/monacoeditorwork/ts.worker.bundle.js +225957 -0
- package/src/web/app.tsx +43 -5
- package/src/web/components/chat/chat-history-panel.tsx +106 -0
- package/src/web/components/chat/chat-tab.tsx +27 -19
- package/src/web/components/editor/code-editor.tsx +101 -173
- package/src/web/components/editor/diff-viewer.tsx +67 -172
- package/src/web/components/git/git-status-panel.tsx +4 -11
- package/src/web/components/layout/add-project-form.tsx +151 -0
- package/src/web/components/layout/command-palette.tsx +3 -1
- package/src/web/components/layout/editor-panel.tsx +6 -4
- package/src/web/components/layout/mobile-drawer.tsx +48 -180
- package/src/web/components/layout/mobile-nav.tsx +89 -6
- package/src/web/components/layout/panel-layout.tsx +16 -10
- package/src/web/components/layout/project-bar.tsx +329 -0
- package/src/web/components/layout/project-bottom-sheet.tsx +345 -0
- package/src/web/components/layout/sidebar.tsx +56 -142
- package/src/web/components/layout/tab-bar.tsx +1 -6
- package/src/web/components/layout/tab-content.tsx +0 -10
- package/src/web/components/ui/dialog.tsx +1 -1
- package/src/web/lib/project-avatar.ts +45 -0
- package/src/web/lib/project-palette.ts +18 -0
- package/src/web/lib/use-monaco-theme.ts +29 -0
- package/src/web/stores/panel-store.ts +96 -9
- package/src/web/stores/project-store.ts +87 -3
- package/src/web/stores/settings-store.ts +52 -5
- package/src/web/stores/tab-store.ts +0 -2
- package/vite.config.ts +6 -2
- package/dist/web/assets/api-client-B_eCZViO.js +0 -1
- package/dist/web/assets/arrow-up-from-line-DjfWTP75.js +0 -1
- package/dist/web/assets/button-CvHWF07y.js +0 -41
- package/dist/web/assets/chat-tab-DJvME48K.js +0 -6
- package/dist/web/assets/code-editor-81Tzd5aV.js +0 -2
- package/dist/web/assets/dialog-Cn5zGuid.js +0 -5
- package/dist/web/assets/diff-viewer-pieRctzs.js +0 -4
- package/dist/web/assets/dist-B6sG2GPc.js +0 -1
- package/dist/web/assets/dist-CBiGQxfr.js +0 -46
- package/dist/web/assets/git-graph-CWI6hxtE.js +0 -1
- package/dist/web/assets/git-status-panel-CAjReViM.js +0 -1
- package/dist/web/assets/index-BdUoflYx.css +0 -2
- package/dist/web/assets/index-CqpLusQd.js +0 -17
- package/dist/web/assets/project-list-MAvAY2K3.js +0 -1
- package/dist/web/assets/react-C32bf_ch.js +0 -1
- package/dist/web/assets/refresh-cw-S6I91MHO.js +0 -1
- package/dist/web/assets/settings-tab-zeZrAFld.js +0 -1
- package/dist/web/assets/trash-2-Dc17nbCE.js +0 -1
- package/dist/web/assets/x-Bpqyw40Y.js +0 -1
- /package/dist/web/assets/{columns-2-DsiY76NQ.js → columns-2-DFQ3yid7.js} +0 -0
- /package/dist/web/assets/{copy-D_Q54D-v.js → copy-B-kLwqzg.js} +0 -0
- /package/dist/web/assets/{external-link-C6Y-D528.js → external-link-Dim3NH6h.js} +0 -0
- /package/dist/web/assets/{marked.esm-Cv8mjgnt.js → marked.esm-DhBtkBa8.js} +0 -0
- /package/dist/web/assets/{utils-61GRB9Cb.js → utils-B-_GCz7E.js} +0 -0
|
@@ -1,42 +1,22 @@
|
|
|
1
|
-
import { useEffect, useState, useMemo
|
|
2
|
-
import {
|
|
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 {
|
|
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
|
|
8
|
+
function getMonacoLanguage(filename: string): string {
|
|
17
9
|
const ext = filename.split(".").pop()?.toLowerCase() ?? "";
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
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
|
|
65
|
-
const
|
|
43
|
+
const { wordWrap, toggleWordWrap } = useSettingsStore();
|
|
44
|
+
const monacoTheme = useMonacoTheme();
|
|
66
45
|
|
|
67
46
|
useEffect(() => {
|
|
68
|
-
if (isInline) return;
|
|
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
|
|
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
|
|
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
|
|
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
|
|
90
|
+
const language = useMemo(() => {
|
|
115
91
|
const langFile = filePath ?? file2 ?? file1;
|
|
116
|
-
|
|
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
|
-
{/*
|
|
296
|
-
<div
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
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
|
|
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("
|
|
320
|
-
line.startsWith("
|
|
321
|
-
line.startsWith("new
|
|
322
|
-
line.startsWith("
|
|
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
|
|
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
|
|
108
|
-
|
|
109
|
-
|
|
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:
|
|
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) =>
|
|
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
|
|