@hienlh/ppm 0.2.21 → 0.4.0
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 +53 -3
- package/dist/web/assets/chat-tab-mOQXOUVI.js +6 -0
- package/dist/web/assets/code-editor-CRgH4vbS.js +1 -0
- package/dist/web/assets/diff-viewer-D3qUDVXh.js +4 -0
- package/dist/web/assets/git-graph-D1SOZKP7.js +1 -0
- package/dist/web/assets/index-C_yeSRZ0.css +2 -0
- package/dist/web/assets/index-CgNJBFj4.js +21 -0
- package/dist/web/assets/input-AESbQWjx.js +41 -0
- package/dist/web/assets/markdown-renderer-BwjbbSR0.js +59 -0
- package/dist/web/assets/settings-store-DWYkr_a3.js +1 -0
- package/dist/web/assets/settings-tab-C-UYksUh.js +1 -0
- package/dist/web/assets/tab-store-B1wzyDLQ.js +1 -0
- package/dist/web/assets/{terminal-tab-BEFAYT4S.js → terminal-tab-BeFf07MH.js} +1 -1
- package/dist/web/assets/use-monaco-theme-Bb9W0CI2.js +11 -0
- package/dist/web/index.html +7 -5
- package/dist/web/sw.js +1 -1
- package/package.json +1 -1
- package/src/providers/claude-agent-sdk.ts +83 -10
- package/src/server/index.ts +81 -1
- package/src/server/ws/chat.ts +10 -0
- package/src/types/api.ts +3 -3
- package/src/types/chat.ts +3 -3
- package/src/web/app.tsx +11 -3
- package/src/web/components/chat/chat-history-bar.tsx +231 -0
- package/src/web/components/chat/chat-tab.tsx +19 -66
- package/src/web/components/chat/message-list.tsx +4 -114
- package/src/web/components/chat/tool-cards.tsx +54 -14
- package/src/web/components/editor/code-editor.tsx +26 -39
- package/src/web/components/editor/diff-viewer.tsx +0 -21
- package/src/web/components/layout/command-palette.tsx +145 -15
- package/src/web/components/layout/draggable-tab.tsx +2 -0
- package/src/web/components/layout/editor-panel.tsx +44 -5
- package/src/web/components/layout/sidebar.tsx +53 -7
- package/src/web/components/layout/tab-bar.tsx +30 -48
- package/src/web/components/settings/ai-settings-section.tsx +28 -19
- package/src/web/components/settings/settings-tab.tsx +24 -21
- package/src/web/components/shared/markdown-renderer.tsx +223 -0
- package/src/web/components/ui/scroll-area.tsx +2 -2
- package/src/web/hooks/use-chat.ts +78 -83
- package/src/web/hooks/use-global-keybindings.ts +30 -2
- package/src/web/stores/panel-store.ts +2 -9
- package/src/web/stores/settings-store.ts +12 -2
- package/src/web/styles/globals.css +14 -4
- package/dist/web/assets/chat-tab-C_U7EwM9.js +0 -6
- package/dist/web/assets/code-editor-DuarTBEe.js +0 -1
- package/dist/web/assets/columns-2-DFQ3yid7.js +0 -1
- package/dist/web/assets/diff-viewer-sBWBgb7U.js +0 -4
- package/dist/web/assets/git-graph-fOKEZiot.js +0 -1
- package/dist/web/assets/index-3zt5mBwZ.css +0 -2
- package/dist/web/assets/index-CaUQy3Zs.js +0 -21
- package/dist/web/assets/input-CTnwfHVN.js +0 -41
- package/dist/web/assets/marked.esm-DhBtkBa8.js +0 -59
- package/dist/web/assets/settings-tab-C5aWMqIA.js +0 -1
- package/dist/web/assets/use-monaco-theme-BxaccPmI.js +0 -11
- /package/dist/web/assets/{api-client-BCjah751.js → api-client-BsHoRDAn.js} +0 -0
- /package/dist/web/assets/{copy-B-kLwqzg.js → copy-BNk4Z75P.js} +0 -0
- /package/dist/web/assets/{external-link-Dim3NH6h.js → external-link-CrtbmtJ6.js} +0 -0
- /package/dist/web/assets/{utils-B-_GCz7E.js → utils-bntUtdc7.js} +0 -0
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { useState, useEffect, useRef, useMemo } from "react";
|
|
1
|
+
import { useState, useEffect, useRef, useMemo, useCallback } from "react";
|
|
2
2
|
import {
|
|
3
3
|
Terminal,
|
|
4
4
|
MessageSquare,
|
|
@@ -7,11 +7,14 @@ import {
|
|
|
7
7
|
Settings,
|
|
8
8
|
Search,
|
|
9
9
|
FileCode,
|
|
10
|
+
FolderOpen,
|
|
11
|
+
Loader2,
|
|
10
12
|
} from "lucide-react";
|
|
11
13
|
import { useTabStore, type TabType } from "@/stores/tab-store";
|
|
12
14
|
import { useProjectStore } from "@/stores/project-store";
|
|
13
15
|
import { useSettingsStore } from "@/stores/settings-store";
|
|
14
16
|
import { useFileStore, type FileNode } from "@/stores/file-store";
|
|
17
|
+
import { api } from "@/lib/api-client";
|
|
15
18
|
|
|
16
19
|
interface CommandItem {
|
|
17
20
|
id: string;
|
|
@@ -20,26 +23,45 @@ interface CommandItem {
|
|
|
20
23
|
icon: React.ElementType;
|
|
21
24
|
action: () => void;
|
|
22
25
|
keywords?: string;
|
|
23
|
-
group: "action" | "file";
|
|
26
|
+
group: "action" | "file" | "fs";
|
|
24
27
|
}
|
|
25
28
|
|
|
26
29
|
/** Recursively flatten file tree into file-only list */
|
|
27
|
-
function flattenFiles(nodes: FileNode[]
|
|
30
|
+
function flattenFiles(nodes: FileNode[]): { name: string; path: string }[] {
|
|
28
31
|
const result: { name: string; path: string }[] = [];
|
|
29
32
|
for (const node of nodes) {
|
|
30
33
|
if (node.type === "file") {
|
|
31
34
|
result.push({ name: node.name, path: node.path });
|
|
32
35
|
}
|
|
33
36
|
if (node.children) {
|
|
34
|
-
result.push(...flattenFiles(node.children
|
|
37
|
+
result.push(...flattenFiles(node.children));
|
|
35
38
|
}
|
|
36
39
|
}
|
|
37
40
|
return result;
|
|
38
41
|
}
|
|
39
42
|
|
|
40
|
-
|
|
43
|
+
/** Check if query looks like an absolute path (Unix: /, ~/ | Windows: C:\, ~\) */
|
|
44
|
+
function isPathQuery(q: string): boolean {
|
|
45
|
+
return q.startsWith("/") || q.startsWith("~/") || q.startsWith("~\\") || /^[A-Za-z]:[/\\]/.test(q);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Extract the directory portion of a path for API call */
|
|
49
|
+
function extractDir(q: string): string {
|
|
50
|
+
// Normalize to forward slash for splitting
|
|
51
|
+
const normalized = q.replace(/\\/g, "/");
|
|
52
|
+
if (normalized.endsWith("/")) return q;
|
|
53
|
+
const lastSlash = Math.max(normalized.lastIndexOf("/"), q.lastIndexOf("\\"));
|
|
54
|
+
return lastSlash > 0 ? q.slice(0, lastSlash + 1) : q;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// Cache: dir path → file list
|
|
58
|
+
const fsCache = new Map<string, string[]>();
|
|
59
|
+
|
|
60
|
+
export function CommandPalette({ open, onClose, initialQuery = "" }: { open: boolean; onClose: () => void; initialQuery?: string }) {
|
|
41
61
|
const [query, setQuery] = useState("");
|
|
42
62
|
const [selectedIdx, setSelectedIdx] = useState(0);
|
|
63
|
+
const [fsFiles, setFsFiles] = useState<string[]>([]);
|
|
64
|
+
const [fsLoading, setFsLoading] = useState(false);
|
|
43
65
|
const inputRef = useRef<HTMLInputElement>(null);
|
|
44
66
|
const listRef = useRef<HTMLDivElement>(null);
|
|
45
67
|
|
|
@@ -47,6 +69,36 @@ export function CommandPalette({ open, onClose }: { open: boolean; onClose: () =
|
|
|
47
69
|
const activeProject = useProjectStore((s) => s.activeProject);
|
|
48
70
|
const fileTree = useFileStore((s) => s.tree);
|
|
49
71
|
const setSidebarActiveTab = useSettingsStore((s) => s.setSidebarActiveTab);
|
|
72
|
+
const sidebarCollapsed = useSettingsStore((s) => s.sidebarCollapsed);
|
|
73
|
+
const toggleSidebar = useSettingsStore((s) => s.toggleSidebar);
|
|
74
|
+
|
|
75
|
+
// Fetch filesystem files when path query changes directory
|
|
76
|
+
const fetchFsFiles = useCallback(async (dir: string) => {
|
|
77
|
+
if (fsCache.has(dir)) {
|
|
78
|
+
setFsFiles(fsCache.get(dir)!);
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
setFsLoading(true);
|
|
82
|
+
try {
|
|
83
|
+
const files = await api.get<string[]>(`/api/fs/list?dir=${encodeURIComponent(dir)}`);
|
|
84
|
+
fsCache.set(dir, files);
|
|
85
|
+
setFsFiles(files);
|
|
86
|
+
} catch {
|
|
87
|
+
setFsFiles([]);
|
|
88
|
+
} finally {
|
|
89
|
+
setFsLoading(false);
|
|
90
|
+
}
|
|
91
|
+
}, []);
|
|
92
|
+
|
|
93
|
+
// When query changes and looks like a path, fetch files
|
|
94
|
+
useEffect(() => {
|
|
95
|
+
if (!isPathQuery(query)) {
|
|
96
|
+
setFsFiles([]);
|
|
97
|
+
return;
|
|
98
|
+
}
|
|
99
|
+
const dir = extractDir(query);
|
|
100
|
+
fetchFsFiles(dir);
|
|
101
|
+
}, [query, fetchFsFiles]);
|
|
50
102
|
|
|
51
103
|
// Action commands
|
|
52
104
|
const actionCommands = useMemo<CommandItem[]>(() => {
|
|
@@ -63,11 +115,20 @@ export function CommandPalette({ open, onClose }: { open: boolean; onClose: () =
|
|
|
63
115
|
{ id: "chat", label: "New AI Chat", icon: MessageSquare, action: openNewTab("chat", "AI Chat"), keywords: "ai assistant claude", group: "action" },
|
|
64
116
|
{ id: "git-graph", label: "Git Graph", icon: GitBranch, action: openNewTab("git-graph", "Git Graph"), keywords: "branch history log", group: "action" },
|
|
65
117
|
{ id: "git-status", label: "Git Status", icon: GitCommitHorizontal, action: () => { setSidebarActiveTab("git"); onClose(); }, keywords: "changes diff staged", group: "action" },
|
|
66
|
-
{
|
|
118
|
+
{
|
|
119
|
+
id: "settings", label: "Settings", icon: Settings,
|
|
120
|
+
action: () => {
|
|
121
|
+
if (sidebarCollapsed) toggleSidebar();
|
|
122
|
+
setSidebarActiveTab("settings");
|
|
123
|
+
onClose();
|
|
124
|
+
},
|
|
125
|
+
keywords: "config preferences theme",
|
|
126
|
+
group: "action",
|
|
127
|
+
},
|
|
67
128
|
];
|
|
68
|
-
}, [activeProject, openTab, onClose]);
|
|
129
|
+
}, [activeProject, openTab, onClose, setSidebarActiveTab, sidebarCollapsed, toggleSidebar]);
|
|
69
130
|
|
|
70
|
-
// File commands — derived from file store tree
|
|
131
|
+
// File commands — derived from file store tree (project files)
|
|
71
132
|
const fileCommands = useMemo<CommandItem[]>(() => {
|
|
72
133
|
const projectId = activeProject?.name ?? null;
|
|
73
134
|
const meta = activeProject ? { projectName: activeProject.name } : undefined;
|
|
@@ -93,12 +154,56 @@ export function CommandPalette({ open, onClose }: { open: boolean; onClose: () =
|
|
|
93
154
|
}));
|
|
94
155
|
}, [fileTree, activeProject, openTab, onClose]);
|
|
95
156
|
|
|
96
|
-
|
|
157
|
+
// Filesystem commands — from cached API results
|
|
158
|
+
const fsCommands = useMemo<CommandItem[]>(() => {
|
|
159
|
+
const projectId = activeProject?.name ?? null;
|
|
160
|
+
const meta = activeProject ? { projectName: activeProject.name } : undefined;
|
|
161
|
+
|
|
162
|
+
return fsFiles.map((fp) => {
|
|
163
|
+
const name = fp.split("/").pop() ?? fp;
|
|
164
|
+
return {
|
|
165
|
+
id: `fs:${fp}`,
|
|
166
|
+
label: name,
|
|
167
|
+
hint: fp,
|
|
168
|
+
icon: FolderOpen,
|
|
169
|
+
group: "fs" as const,
|
|
170
|
+
keywords: fp,
|
|
171
|
+
action: () => {
|
|
172
|
+
openTab({
|
|
173
|
+
type: "editor",
|
|
174
|
+
title: name,
|
|
175
|
+
projectId,
|
|
176
|
+
metadata: { ...meta, filePath: fp },
|
|
177
|
+
closable: true,
|
|
178
|
+
});
|
|
179
|
+
onClose();
|
|
180
|
+
},
|
|
181
|
+
};
|
|
182
|
+
});
|
|
183
|
+
}, [fsFiles, activeProject, openTab, onClose]);
|
|
184
|
+
|
|
185
|
+
const allCommands = useMemo(
|
|
186
|
+
() => [...actionCommands, ...fileCommands],
|
|
187
|
+
[actionCommands, fileCommands],
|
|
188
|
+
);
|
|
97
189
|
|
|
98
190
|
const filtered = useMemo(() => {
|
|
99
|
-
|
|
191
|
+
// Path mode — search filesystem results using filename portion only
|
|
192
|
+
if (isPathQuery(query)) {
|
|
193
|
+
// Extract the part after the last / as the filename filter
|
|
194
|
+
const lastSlash = query.lastIndexOf("/");
|
|
195
|
+
const fileFilter = lastSlash >= 0 ? query.slice(lastSlash + 1).toLowerCase() : "";
|
|
196
|
+
if (!fileFilter) return fsCommands.slice(0, 50); // show all if query ends with /
|
|
197
|
+
return fsCommands.filter((c) => {
|
|
198
|
+
const name = c.label.toLowerCase();
|
|
199
|
+
const path = (c.keywords ?? "").toLowerCase();
|
|
200
|
+
return name.includes(fileFilter) || path.includes(fileFilter);
|
|
201
|
+
}).slice(0, 50);
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// Normal mode
|
|
205
|
+
if (!query.trim()) return actionCommands;
|
|
100
206
|
const q = query.toLowerCase();
|
|
101
|
-
// Fuzzy-ish: every character of query must appear in order
|
|
102
207
|
const matchesFuzzy = (text: string) => {
|
|
103
208
|
let ti = 0;
|
|
104
209
|
for (let qi = 0; qi < q.length; qi++) {
|
|
@@ -111,13 +216,14 @@ export function CommandPalette({ open, onClose }: { open: boolean; onClose: () =
|
|
|
111
216
|
return allCommands.filter(
|
|
112
217
|
(c) => matchesFuzzy(c.label.toLowerCase()) || (c.keywords && matchesFuzzy(c.keywords.toLowerCase())),
|
|
113
218
|
);
|
|
114
|
-
}, [allCommands, actionCommands, query]);
|
|
219
|
+
}, [allCommands, actionCommands, fsCommands, query]);
|
|
115
220
|
|
|
116
221
|
// Reset state when opening
|
|
117
222
|
useEffect(() => {
|
|
118
223
|
if (open) {
|
|
119
|
-
setQuery(
|
|
224
|
+
setQuery(initialQuery);
|
|
120
225
|
setSelectedIdx(0);
|
|
226
|
+
setFsFiles([]);
|
|
121
227
|
requestAnimationFrame(() => inputRef.current?.focus());
|
|
122
228
|
}
|
|
123
229
|
}, [open]);
|
|
@@ -158,6 +264,8 @@ export function CommandPalette({ open, onClose }: { open: boolean; onClose: () =
|
|
|
158
264
|
|
|
159
265
|
if (!open) return null;
|
|
160
266
|
|
|
267
|
+
const pathMode = isPathQuery(query);
|
|
268
|
+
|
|
161
269
|
return (
|
|
162
270
|
<div className="fixed inset-0 z-50 flex items-start justify-center pt-[20vh]" onClick={onClose}>
|
|
163
271
|
<div className="fixed inset-0 bg-black/50" />
|
|
@@ -174,18 +282,28 @@ export function CommandPalette({ open, onClose }: { open: boolean; onClose: () =
|
|
|
174
282
|
type="text"
|
|
175
283
|
value={query}
|
|
176
284
|
onChange={(e) => setQuery(e.target.value)}
|
|
177
|
-
placeholder="Search actions & files..."
|
|
285
|
+
placeholder="Search actions & files... (type / or ~/ for filesystem)"
|
|
178
286
|
className="flex-1 bg-transparent text-sm text-text-primary outline-none placeholder:text-text-subtle"
|
|
179
287
|
/>
|
|
288
|
+
{fsLoading && <Loader2 className="size-3.5 animate-spin text-text-subtle shrink-0" />}
|
|
180
289
|
<kbd className="hidden sm:inline-flex items-center rounded border border-border bg-surface px-1.5 py-0.5 text-[10px] text-text-subtle font-mono">
|
|
181
290
|
ESC
|
|
182
291
|
</kbd>
|
|
183
292
|
</div>
|
|
184
293
|
|
|
294
|
+
{/* Path mode hint */}
|
|
295
|
+
{pathMode && !fsLoading && fsFiles.length === 0 && query.length < 4 && (
|
|
296
|
+
<div className="px-3 py-2 text-xs text-text-subtle border-b border-border/50">
|
|
297
|
+
Type a directory path to browse files (e.g. ~/Projects/)
|
|
298
|
+
</div>
|
|
299
|
+
)}
|
|
300
|
+
|
|
185
301
|
{/* Results */}
|
|
186
302
|
<div ref={listRef} className="max-h-72 overflow-y-auto py-1">
|
|
187
303
|
{filtered.length === 0 ? (
|
|
188
|
-
<p className="px-3 py-4 text-sm text-text-subtle text-center">
|
|
304
|
+
<p className="px-3 py-4 text-sm text-text-subtle text-center">
|
|
305
|
+
{fsLoading ? "Searching..." : "No results"}
|
|
306
|
+
</p>
|
|
189
307
|
) : (
|
|
190
308
|
filtered.map((cmd, i) => {
|
|
191
309
|
const Icon = cmd.icon;
|
|
@@ -211,6 +329,18 @@ export function CommandPalette({ open, onClose }: { open: boolean; onClose: () =
|
|
|
211
329
|
})
|
|
212
330
|
)}
|
|
213
331
|
</div>
|
|
332
|
+
|
|
333
|
+
{/* Shortcut hint */}
|
|
334
|
+
<div className="flex items-center justify-center gap-1.5 border-t border-border px-3 py-1.5">
|
|
335
|
+
<span className="text-[10px] text-text-subtle">Press</span>
|
|
336
|
+
<kbd className="inline-flex items-center rounded border border-border bg-surface px-1 py-0.5 text-[10px] text-text-subtle font-mono">
|
|
337
|
+
Shift
|
|
338
|
+
</kbd>
|
|
339
|
+
<kbd className="inline-flex items-center rounded border border-border bg-surface px-1 py-0.5 text-[10px] text-text-subtle font-mono">
|
|
340
|
+
Shift
|
|
341
|
+
</kbd>
|
|
342
|
+
<span className="text-[10px] text-text-subtle">to open this palette</span>
|
|
343
|
+
</div>
|
|
214
344
|
</div>
|
|
215
345
|
</div>
|
|
216
346
|
);
|
|
@@ -26,8 +26,10 @@ export function DraggableTab({
|
|
|
26
26
|
)}
|
|
27
27
|
<button
|
|
28
28
|
ref={tabRef}
|
|
29
|
+
data-tab-item
|
|
29
30
|
draggable
|
|
30
31
|
onClick={onSelect}
|
|
32
|
+
onAuxClick={(e) => { if (e.button === 1 && tab.closable) { e.preventDefault(); onClose(); } }}
|
|
31
33
|
onDragStart={onDragStart}
|
|
32
34
|
onDragOver={onDragOver}
|
|
33
35
|
onDragEnd={onDragEnd}
|
|
@@ -1,11 +1,18 @@
|
|
|
1
1
|
import { Suspense, lazy } from "react";
|
|
2
|
-
import { Loader2 } from "lucide-react";
|
|
2
|
+
import { Loader2, Terminal, MessageSquare, GitBranch } from "lucide-react";
|
|
3
3
|
import { usePanelStore } from "@/stores/panel-store";
|
|
4
|
+
import { useProjectStore } from "@/stores/project-store";
|
|
4
5
|
import type { TabType } from "@/stores/tab-store";
|
|
5
6
|
import { TabBar } from "./tab-bar";
|
|
6
7
|
import { SplitDropOverlay } from "./split-drop-overlay";
|
|
7
8
|
import { cn } from "@/lib/utils";
|
|
8
9
|
|
|
10
|
+
const QUICK_OPEN_TABS: { type: TabType; label: string; icon: React.ElementType }[] = [
|
|
11
|
+
{ type: "terminal", label: "Terminal", icon: Terminal },
|
|
12
|
+
{ type: "chat", label: "AI Chat", icon: MessageSquare },
|
|
13
|
+
{ type: "git-graph", label: "Git Graph", icon: GitBranch },
|
|
14
|
+
];
|
|
15
|
+
|
|
9
16
|
const TAB_COMPONENTS: Record<TabType, React.LazyExoticComponent<React.ComponentType<{ metadata?: Record<string, unknown>; tabId?: string }>>> = {
|
|
10
17
|
terminal: lazy(() => import("@/components/terminal/terminal-tab").then((m) => ({ default: m.TerminalTab }))),
|
|
11
18
|
chat: lazy(() => import("@/components/chat/chat-tab").then((m) => ({ default: m.ChatTab }))),
|
|
@@ -37,15 +44,13 @@ export function EditorPanel({ panelId, projectName }: EditorPanelProps) {
|
|
|
37
44
|
panelCount > 1 && "border border-transparent",
|
|
38
45
|
panelCount > 1 && isFocused && "border-primary/30",
|
|
39
46
|
)}
|
|
40
|
-
|
|
47
|
+
onMouseDown={() => { if (usePanelStore.getState().focusedPanelId !== panelId) usePanelStore.getState().setFocusedPanel(panelId); }}
|
|
41
48
|
>
|
|
42
49
|
<TabBar panelId={panelId} />
|
|
43
50
|
|
|
44
51
|
<div className="flex-1 overflow-hidden relative">
|
|
45
52
|
{panel.tabs.length === 0 ? (
|
|
46
|
-
<
|
|
47
|
-
Drop a tab here
|
|
48
|
-
</div>
|
|
53
|
+
<EmptyPanel panelId={panelId} />
|
|
49
54
|
) : (
|
|
50
55
|
panel.tabs.map((tab) => {
|
|
51
56
|
const Component = TAB_COMPONENTS[tab.type];
|
|
@@ -64,3 +69,37 @@ export function EditorPanel({ panelId, projectName }: EditorPanelProps) {
|
|
|
64
69
|
</div>
|
|
65
70
|
);
|
|
66
71
|
}
|
|
72
|
+
|
|
73
|
+
function EmptyPanel({ panelId }: { panelId: string }) {
|
|
74
|
+
const activeProject = useProjectStore((s) => s.activeProject);
|
|
75
|
+
|
|
76
|
+
function openTab(type: TabType) {
|
|
77
|
+
const needsProject = type !== "settings";
|
|
78
|
+
const metadata = needsProject && activeProject ? { projectName: activeProject.name } : undefined;
|
|
79
|
+
usePanelStore.getState().openTab(
|
|
80
|
+
{ type, title: QUICK_OPEN_TABS.find((t) => t.type === type)?.label ?? type, metadata, projectId: activeProject?.name ?? null, closable: true },
|
|
81
|
+
panelId,
|
|
82
|
+
);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
return (
|
|
86
|
+
<div className="flex flex-col items-center justify-center h-full gap-4 text-text-secondary">
|
|
87
|
+
<p className="text-sm">Open a tab to get started</p>
|
|
88
|
+
<div className="flex flex-col md:flex-row flex-wrap justify-center gap-2">
|
|
89
|
+
{QUICK_OPEN_TABS.map((opt) => {
|
|
90
|
+
const Icon = opt.icon;
|
|
91
|
+
return (
|
|
92
|
+
<button
|
|
93
|
+
key={opt.type}
|
|
94
|
+
onClick={() => openTab(opt.type)}
|
|
95
|
+
className="flex items-center gap-2 px-4 py-2 rounded-md border border-border bg-surface hover:bg-surface-elevated text-sm text-foreground transition-colors"
|
|
96
|
+
>
|
|
97
|
+
<Icon className="size-4" />
|
|
98
|
+
{opt.label}
|
|
99
|
+
</button>
|
|
100
|
+
);
|
|
101
|
+
})}
|
|
102
|
+
</div>
|
|
103
|
+
</div>
|
|
104
|
+
);
|
|
105
|
+
}
|
|
@@ -1,21 +1,62 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { useCallback, useRef } from "react";
|
|
2
|
+
import { PanelLeftClose, PanelLeftOpen, FolderOpen, GitBranch, Settings } from "lucide-react";
|
|
2
3
|
import { useProjectStore } from "@/stores/project-store";
|
|
3
4
|
import { useSettingsStore, type SidebarActiveTab } from "@/stores/settings-store";
|
|
4
5
|
import { FileTree } from "@/components/explorer/file-tree";
|
|
5
6
|
import { GitStatusPanel } from "@/components/git/git-status-panel";
|
|
6
|
-
import {
|
|
7
|
+
import { SettingsTab } from "@/components/settings/settings-tab";
|
|
7
8
|
import { cn } from "@/lib/utils";
|
|
8
9
|
|
|
9
10
|
const TABS: { id: SidebarActiveTab; label: string; icon: React.ElementType }[] = [
|
|
10
11
|
{ id: "explorer", label: "Explorer", icon: FolderOpen },
|
|
11
12
|
{ id: "git", label: "Git", icon: GitBranch },
|
|
12
|
-
{ id: "
|
|
13
|
+
{ id: "settings", label: "Settings", icon: Settings },
|
|
13
14
|
];
|
|
14
15
|
|
|
16
|
+
function ResizeHandle({ onResize }: { onResize: (width: number) => void }) {
|
|
17
|
+
const dragging = useRef(false);
|
|
18
|
+
|
|
19
|
+
const handlePointerDown = useCallback((e: React.PointerEvent) => {
|
|
20
|
+
e.preventDefault();
|
|
21
|
+
dragging.current = true;
|
|
22
|
+
const target = e.currentTarget as HTMLElement;
|
|
23
|
+
target.setPointerCapture(e.pointerId);
|
|
24
|
+
document.body.style.cursor = "col-resize";
|
|
25
|
+
document.body.style.userSelect = "none";
|
|
26
|
+
}, []);
|
|
27
|
+
|
|
28
|
+
const handlePointerMove = useCallback((e: React.PointerEvent) => {
|
|
29
|
+
if (!dragging.current) return;
|
|
30
|
+
// Sidebar starts after the project bar (48px wide)
|
|
31
|
+
const projectBarWidth = 48;
|
|
32
|
+
const newWidth = e.clientX - projectBarWidth;
|
|
33
|
+
onResize(newWidth);
|
|
34
|
+
}, [onResize]);
|
|
35
|
+
|
|
36
|
+
const handlePointerUp = useCallback((e: React.PointerEvent) => {
|
|
37
|
+
dragging.current = false;
|
|
38
|
+
const target = e.currentTarget as HTMLElement;
|
|
39
|
+
target.releasePointerCapture(e.pointerId);
|
|
40
|
+
document.body.style.cursor = "";
|
|
41
|
+
document.body.style.userSelect = "";
|
|
42
|
+
}, []);
|
|
43
|
+
|
|
44
|
+
return (
|
|
45
|
+
<div
|
|
46
|
+
className="absolute right-0 top-0 bottom-0 w-1 cursor-col-resize hover:bg-primary/30 active:bg-primary/50 transition-colors z-10"
|
|
47
|
+
onPointerDown={handlePointerDown}
|
|
48
|
+
onPointerMove={handlePointerMove}
|
|
49
|
+
onPointerUp={handlePointerUp}
|
|
50
|
+
/>
|
|
51
|
+
);
|
|
52
|
+
}
|
|
53
|
+
|
|
15
54
|
export function Sidebar() {
|
|
16
55
|
const { activeProject } = useProjectStore();
|
|
17
56
|
const sidebarCollapsed = useSettingsStore((s) => s.sidebarCollapsed);
|
|
57
|
+
const sidebarWidth = useSettingsStore((s) => s.sidebarWidth);
|
|
18
58
|
const toggleSidebar = useSettingsStore((s) => s.toggleSidebar);
|
|
59
|
+
const setSidebarWidth = useSettingsStore((s) => s.setSidebarWidth);
|
|
19
60
|
const sidebarActiveTab = useSettingsStore((s) => s.sidebarActiveTab);
|
|
20
61
|
const setSidebarActiveTab = useSettingsStore((s) => s.setSidebarActiveTab);
|
|
21
62
|
|
|
@@ -34,8 +75,11 @@ export function Sidebar() {
|
|
|
34
75
|
}
|
|
35
76
|
|
|
36
77
|
return (
|
|
37
|
-
<aside
|
|
38
|
-
|
|
78
|
+
<aside
|
|
79
|
+
className="hidden md:flex flex-col bg-background border-r border-border overflow-hidden relative"
|
|
80
|
+
style={{ width: sidebarWidth, minWidth: 200, maxWidth: 600 }}
|
|
81
|
+
>
|
|
82
|
+
{/* Tab bar */}
|
|
39
83
|
<div className="flex items-center h-[41px] border-b border-border shrink-0">
|
|
40
84
|
{TABS.map((tab) => {
|
|
41
85
|
const Icon = tab.icon;
|
|
@@ -79,11 +123,13 @@ export function Sidebar() {
|
|
|
79
123
|
{sidebarActiveTab === "git" && (
|
|
80
124
|
<GitStatusPanel metadata={{ projectName: activeProject?.name }} />
|
|
81
125
|
)}
|
|
82
|
-
{sidebarActiveTab === "
|
|
83
|
-
<
|
|
126
|
+
{sidebarActiveTab === "settings" && (
|
|
127
|
+
<SettingsTab />
|
|
84
128
|
)}
|
|
85
129
|
</div>
|
|
86
130
|
|
|
131
|
+
{/* Resize handle */}
|
|
132
|
+
<ResizeHandle onResize={setSidebarWidth} />
|
|
87
133
|
</aside>
|
|
88
134
|
);
|
|
89
135
|
}
|
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
import { useEffect, useRef } from "react";
|
|
2
2
|
import {
|
|
3
|
-
X,
|
|
4
3
|
Plus,
|
|
5
4
|
Terminal,
|
|
6
5
|
MessageSquare,
|
|
@@ -9,17 +8,11 @@ import {
|
|
|
9
8
|
Settings,
|
|
10
9
|
FileCode,
|
|
11
10
|
} from "lucide-react";
|
|
12
|
-
import {
|
|
13
|
-
DropdownMenu,
|
|
14
|
-
DropdownMenuContent,
|
|
15
|
-
DropdownMenuItem,
|
|
16
|
-
DropdownMenuTrigger,
|
|
17
|
-
} from "@/components/ui/dropdown-menu";
|
|
18
|
-
import { ScrollArea, ScrollBar } from "@/components/ui/scroll-area";
|
|
19
11
|
import { useTabStore, type TabType } from "@/stores/tab-store";
|
|
20
12
|
import { usePanelStore } from "@/stores/panel-store";
|
|
21
13
|
import { useProjectStore } from "@/stores/project-store";
|
|
22
14
|
import { useTabDrag } from "@/hooks/use-tab-drag";
|
|
15
|
+
import { openCommandPalette } from "@/hooks/use-global-keybindings";
|
|
23
16
|
import { DraggableTab } from "./draggable-tab";
|
|
24
17
|
|
|
25
18
|
const TAB_ICONS: Record<TabType, React.ElementType> = {
|
|
@@ -31,13 +24,6 @@ const TAB_ICONS: Record<TabType, React.ElementType> = {
|
|
|
31
24
|
settings: Settings,
|
|
32
25
|
};
|
|
33
26
|
|
|
34
|
-
const NEW_TAB_OPTIONS: { type: TabType; label: string }[] = [
|
|
35
|
-
{ type: "terminal", label: "Terminal" },
|
|
36
|
-
{ type: "chat", label: "AI Chat" },
|
|
37
|
-
{ type: "git-graph", label: "Git Graph" },
|
|
38
|
-
{ type: "settings", label: "Settings" },
|
|
39
|
-
];
|
|
40
|
-
|
|
41
27
|
interface TabBarProps {
|
|
42
28
|
panelId?: string;
|
|
43
29
|
}
|
|
@@ -45,6 +31,7 @@ interface TabBarProps {
|
|
|
45
31
|
export function TabBar({ panelId }: TabBarProps) {
|
|
46
32
|
const activeProject = useProjectStore((s) => s.activeProject);
|
|
47
33
|
const tabRefs = useRef<Map<string, HTMLButtonElement>>(new Map());
|
|
34
|
+
const scrollRef = useRef<HTMLDivElement>(null);
|
|
48
35
|
const prevTabCount = useRef(0);
|
|
49
36
|
|
|
50
37
|
// Read tabs from panel-store if panelId given, else from tab-store (focused)
|
|
@@ -65,20 +52,20 @@ export function TabBar({ panelId }: TabBarProps) {
|
|
|
65
52
|
prevTabCount.current = tabs.length;
|
|
66
53
|
}, [tabs.length, activeTabId]);
|
|
67
54
|
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
55
|
+
/** Double-click on empty bar area → open command palette */
|
|
56
|
+
function handleBarDoubleClick(e: React.MouseEvent) {
|
|
57
|
+
// Only trigger if clicking directly on the bar or scroll container (not on a tab)
|
|
58
|
+
const target = e.target as HTMLElement;
|
|
59
|
+
if (target.closest("[data-tab-item]")) return;
|
|
60
|
+
openCommandPalette();
|
|
61
|
+
}
|
|
71
62
|
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
closable: true,
|
|
79
|
-
},
|
|
80
|
-
effectivePanelId,
|
|
81
|
-
);
|
|
63
|
+
/** Right-click on empty bar area → open command palette */
|
|
64
|
+
function handleBarContextMenu(e: React.MouseEvent) {
|
|
65
|
+
const target = e.target as HTMLElement;
|
|
66
|
+
if (target.closest("[data-tab-item]")) return;
|
|
67
|
+
e.preventDefault();
|
|
68
|
+
openCommandPalette();
|
|
82
69
|
}
|
|
83
70
|
|
|
84
71
|
return (
|
|
@@ -86,8 +73,14 @@ export function TabBar({ panelId }: TabBarProps) {
|
|
|
86
73
|
className="hidden md:flex items-center h-[41px] border-b border-border bg-background"
|
|
87
74
|
onDragOver={handleDragOverBar}
|
|
88
75
|
onDrop={handleDrop}
|
|
76
|
+
onDoubleClick={handleBarDoubleClick}
|
|
77
|
+
onContextMenu={handleBarContextMenu}
|
|
89
78
|
>
|
|
90
|
-
|
|
79
|
+
{/* Scrollable tabs + sticky + button */}
|
|
80
|
+
<div
|
|
81
|
+
ref={scrollRef}
|
|
82
|
+
className="flex-1 overflow-x-auto overflow-y-hidden min-w-0 scrollbar-none"
|
|
83
|
+
>
|
|
91
84
|
<div className="flex items-center gap-0.5 px-2 py-1">
|
|
92
85
|
{tabs.map((tab, i) => (
|
|
93
86
|
<DraggableTab
|
|
@@ -111,28 +104,17 @@ export function TabBar({ panelId }: TabBarProps) {
|
|
|
111
104
|
{dropIndex !== null && dropIndex >= tabs.length && (
|
|
112
105
|
<div className="w-0.5 h-6 bg-primary rounded-full" />
|
|
113
106
|
)}
|
|
114
|
-
</div>
|
|
115
|
-
<ScrollBar orientation="horizontal" />
|
|
116
|
-
</ScrollArea>
|
|
117
107
|
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
108
|
+
{/* + button — inside flow, sticky when overflowing */}
|
|
109
|
+
<button
|
|
110
|
+
onClick={openCommandPalette}
|
|
111
|
+
title="Open command palette (Shift+Shift)"
|
|
112
|
+
className="flex items-center justify-center size-7 shrink-0 sticky right-1 rounded-md text-text-secondary hover:text-foreground hover:bg-surface-elevated transition-colors bg-background"
|
|
113
|
+
>
|
|
121
114
|
<Plus className="size-4" />
|
|
122
115
|
</button>
|
|
123
|
-
</
|
|
124
|
-
|
|
125
|
-
{NEW_TAB_OPTIONS.map((opt) => {
|
|
126
|
-
const Icon = TAB_ICONS[opt.type];
|
|
127
|
-
return (
|
|
128
|
-
<DropdownMenuItem key={opt.type} onClick={() => handleNewTab(opt.type)}>
|
|
129
|
-
<Icon className="size-4 mr-2" />
|
|
130
|
-
{opt.label}
|
|
131
|
-
</DropdownMenuItem>
|
|
132
|
-
);
|
|
133
|
-
})}
|
|
134
|
-
</DropdownMenuContent>
|
|
135
|
-
</DropdownMenu>
|
|
116
|
+
</div>
|
|
117
|
+
</div>
|
|
136
118
|
</div>
|
|
137
119
|
);
|
|
138
120
|
}
|