@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,48 +1,23 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
4
|
-
import { useTabStore } from "@/stores/tab-store";
|
|
1
|
+
import { PanelLeftClose, PanelLeftOpen, FolderOpen, GitBranch, MessageSquare } from "lucide-react";
|
|
2
|
+
import { useProjectStore } from "@/stores/project-store";
|
|
3
|
+
import { useSettingsStore, type SidebarActiveTab } from "@/stores/settings-store";
|
|
5
4
|
import { FileTree } from "@/components/explorer/file-tree";
|
|
6
|
-
import {
|
|
7
|
-
|
|
8
|
-
DropdownMenuContent,
|
|
9
|
-
DropdownMenuSeparator,
|
|
10
|
-
DropdownMenuTrigger,
|
|
11
|
-
} from "@/components/ui/dropdown-menu";
|
|
12
|
-
import { useSettingsStore } from "@/stores/settings-store";
|
|
5
|
+
import { GitStatusPanel } from "@/components/git/git-status-panel";
|
|
6
|
+
import { ChatHistoryPanel } from "@/components/chat/chat-history-panel";
|
|
13
7
|
import { cn } from "@/lib/utils";
|
|
14
|
-
import { openBugReport } from "@/lib/report-bug";
|
|
15
8
|
|
|
16
|
-
|
|
17
|
-
|
|
9
|
+
const TABS: { id: SidebarActiveTab; label: string; icon: React.ElementType }[] = [
|
|
10
|
+
{ id: "explorer", label: "Explorer", icon: FolderOpen },
|
|
11
|
+
{ id: "git", label: "Git", icon: GitBranch },
|
|
12
|
+
{ id: "history", label: "History", icon: MessageSquare },
|
|
13
|
+
];
|
|
18
14
|
|
|
19
15
|
export function Sidebar() {
|
|
20
|
-
const {
|
|
21
|
-
useProjectStore();
|
|
22
|
-
const openTab = useTabStore((s) => s.openTab);
|
|
23
|
-
const deviceName = useSettingsStore((s) => s.deviceName);
|
|
24
|
-
const version = useSettingsStore((s) => s.version);
|
|
16
|
+
const { activeProject } = useProjectStore();
|
|
25
17
|
const sidebarCollapsed = useSettingsStore((s) => s.sidebarCollapsed);
|
|
26
18
|
const toggleSidebar = useSettingsStore((s) => s.toggleSidebar);
|
|
27
|
-
const
|
|
28
|
-
|
|
29
|
-
const sorted = useMemo(() => sortByRecent(projects), [projects]);
|
|
30
|
-
|
|
31
|
-
const filtered = useMemo(() => {
|
|
32
|
-
if (!query.trim()) return sorted.slice(0, MAX_VISIBLE);
|
|
33
|
-
const q = query.toLowerCase();
|
|
34
|
-
return sorted.filter(
|
|
35
|
-
(p) => p.name.toLowerCase().includes(q) || p.path.toLowerCase().includes(q),
|
|
36
|
-
);
|
|
37
|
-
}, [sorted, query]);
|
|
38
|
-
|
|
39
|
-
const showSearch = projects.length > MAX_VISIBLE || query.length > 0;
|
|
40
|
-
|
|
41
|
-
function handleAddProject() {
|
|
42
|
-
openTab({ type: "projects", title: "Projects", projectId: null, closable: true });
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
const handleReportBug = useCallback(() => openBugReport(version), [version]);
|
|
19
|
+
const sidebarActiveTab = useSettingsStore((s) => s.sidebarActiveTab);
|
|
20
|
+
const setSidebarActiveTab = useSettingsStore((s) => s.setSidebarActiveTab);
|
|
46
21
|
|
|
47
22
|
if (sidebarCollapsed) {
|
|
48
23
|
return (
|
|
@@ -60,116 +35,55 @@ export function Sidebar() {
|
|
|
60
35
|
|
|
61
36
|
return (
|
|
62
37
|
<aside className="hidden md:flex flex-col w-[280px] min-w-[280px] bg-background border-r border-border overflow-hidden">
|
|
63
|
-
{/*
|
|
64
|
-
<div className="flex items-center
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
</span>
|
|
70
|
-
)}
|
|
71
|
-
|
|
72
|
-
<DropdownMenu onOpenChange={() => setQuery("")}>
|
|
73
|
-
<DropdownMenuTrigger asChild>
|
|
74
|
-
<button className="flex items-center gap-1.5 px-2 py-1 rounded-md hover:bg-surface-elevated transition-colors min-w-0 flex-1">
|
|
75
|
-
<FolderOpen className="size-3.5 text-text-subtle shrink-0" />
|
|
76
|
-
<span className="text-sm truncate flex-1 text-left">
|
|
77
|
-
{activeProject?.name ?? "Select Project"}
|
|
78
|
-
</span>
|
|
79
|
-
<ChevronDown className="size-3 text-text-subtle shrink-0" />
|
|
80
|
-
</button>
|
|
81
|
-
</DropdownMenuTrigger>
|
|
82
|
-
<DropdownMenuContent align="start" className="w-[360px] p-0">
|
|
83
|
-
{/* Search — only when many projects */}
|
|
84
|
-
{showSearch && (
|
|
85
|
-
<div className="flex items-center gap-2 px-2.5 py-2 border-b border-border">
|
|
86
|
-
<Search className="size-3.5 text-text-subtle shrink-0" />
|
|
87
|
-
<input
|
|
88
|
-
type="text"
|
|
89
|
-
value={query}
|
|
90
|
-
onChange={(e) => setQuery(e.target.value)}
|
|
91
|
-
placeholder="Search projects..."
|
|
92
|
-
className="flex-1 bg-transparent text-sm outline-none placeholder:text-text-subtle text-text-primary"
|
|
93
|
-
autoFocus
|
|
94
|
-
/>
|
|
95
|
-
</div>
|
|
96
|
-
)}
|
|
97
|
-
|
|
98
|
-
{/* Project list */}
|
|
99
|
-
<div className="max-h-64 overflow-y-auto py-1">
|
|
100
|
-
{loading && (
|
|
101
|
-
<p className="px-3 py-1.5 text-xs text-text-secondary">Loading...</p>
|
|
102
|
-
)}
|
|
103
|
-
{!loading && filtered.length === 0 && (
|
|
104
|
-
<p className="px-3 py-2 text-xs text-text-subtle text-center">
|
|
105
|
-
{query ? "No matches" : "No projects"}
|
|
106
|
-
</p>
|
|
107
|
-
)}
|
|
108
|
-
{filtered.map((project) => (
|
|
109
|
-
<button
|
|
110
|
-
key={project.name}
|
|
111
|
-
onClick={() => setActiveProject(project)}
|
|
112
|
-
className={cn(
|
|
113
|
-
"w-full flex items-center gap-2 px-3 py-1.5 text-sm text-left transition-colors hover:bg-surface-elevated",
|
|
114
|
-
activeProject?.name === project.name && "bg-accent/10",
|
|
115
|
-
)}
|
|
116
|
-
>
|
|
117
|
-
<FolderOpen className="size-3.5 shrink-0 text-text-subtle" />
|
|
118
|
-
<span className="truncate font-semibold text-text-primary">{project.name}</span>
|
|
119
|
-
<span className="truncate text-xs text-text-subtle ml-auto">{project.path}</span>
|
|
120
|
-
{activeProject?.name === project.name && (
|
|
121
|
-
<Check className="size-3.5 text-primary shrink-0" />
|
|
122
|
-
)}
|
|
123
|
-
</button>
|
|
124
|
-
))}
|
|
125
|
-
</div>
|
|
126
|
-
|
|
127
|
-
<DropdownMenuSeparator className="my-0" />
|
|
38
|
+
{/* Tab bar (replaces old header) */}
|
|
39
|
+
<div className="flex items-center h-[41px] border-b border-border shrink-0">
|
|
40
|
+
{TABS.map((tab) => {
|
|
41
|
+
const Icon = tab.icon;
|
|
42
|
+
const isActive = sidebarActiveTab === tab.id;
|
|
43
|
+
return (
|
|
128
44
|
<button
|
|
129
|
-
|
|
130
|
-
|
|
45
|
+
key={tab.id}
|
|
46
|
+
onClick={() => setSidebarActiveTab(tab.id)}
|
|
47
|
+
className={cn(
|
|
48
|
+
"flex-1 flex items-center justify-center gap-1.5 h-full text-xs transition-colors border-b-2 -mb-px",
|
|
49
|
+
isActive
|
|
50
|
+
? "border-primary text-primary font-medium"
|
|
51
|
+
: "border-transparent text-text-secondary hover:text-foreground",
|
|
52
|
+
)}
|
|
131
53
|
>
|
|
132
|
-
<
|
|
133
|
-
<span>
|
|
54
|
+
<Icon className="size-3.5" />
|
|
55
|
+
<span>{tab.label}</span>
|
|
134
56
|
</button>
|
|
135
|
-
|
|
136
|
-
|
|
57
|
+
);
|
|
58
|
+
})}
|
|
59
|
+
<button
|
|
60
|
+
onClick={toggleSidebar}
|
|
61
|
+
title="Collapse sidebar (⌘B)"
|
|
62
|
+
className="flex items-center justify-center w-8 h-full text-text-subtle hover:text-text-secondary transition-colors shrink-0"
|
|
63
|
+
>
|
|
64
|
+
<PanelLeftClose className="size-3.5" />
|
|
65
|
+
</button>
|
|
137
66
|
</div>
|
|
138
67
|
|
|
139
|
-
{/*
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
<button
|
|
157
|
-
onClick={handleReportBug}
|
|
158
|
-
title="Report a bug"
|
|
159
|
-
className="flex items-center gap-1 text-[10px] text-text-subtle hover:text-text-secondary transition-colors"
|
|
160
|
-
>
|
|
161
|
-
<Bug className="size-3" />
|
|
162
|
-
<span>Report Bug</span>
|
|
163
|
-
</button>
|
|
164
|
-
<button
|
|
165
|
-
onClick={toggleSidebar}
|
|
166
|
-
title="Collapse sidebar (⌘B)"
|
|
167
|
-
className="text-text-subtle hover:text-text-secondary transition-colors"
|
|
168
|
-
>
|
|
169
|
-
<PanelLeftClose className="size-3.5" />
|
|
170
|
-
</button>
|
|
171
|
-
</div>
|
|
68
|
+
{/* Tab content */}
|
|
69
|
+
<div className="flex-1 overflow-y-auto min-h-0">
|
|
70
|
+
{sidebarActiveTab === "explorer" && (
|
|
71
|
+
activeProject ? (
|
|
72
|
+
<FileTree />
|
|
73
|
+
) : (
|
|
74
|
+
<div className="flex items-center justify-center h-24 p-4">
|
|
75
|
+
<p className="text-xs text-text-subtle text-center">Select a project to browse files</p>
|
|
76
|
+
</div>
|
|
77
|
+
)
|
|
78
|
+
)}
|
|
79
|
+
{sidebarActiveTab === "git" && (
|
|
80
|
+
<GitStatusPanel metadata={{ projectName: activeProject?.name }} />
|
|
81
|
+
)}
|
|
82
|
+
{sidebarActiveTab === "history" && (
|
|
83
|
+
<ChatHistoryPanel projectName={activeProject?.name} />
|
|
84
|
+
)}
|
|
172
85
|
</div>
|
|
86
|
+
|
|
173
87
|
</aside>
|
|
174
88
|
);
|
|
175
89
|
}
|
|
@@ -2,11 +2,9 @@ import { useEffect, useRef } from "react";
|
|
|
2
2
|
import {
|
|
3
3
|
X,
|
|
4
4
|
Plus,
|
|
5
|
-
FolderOpen,
|
|
6
5
|
Terminal,
|
|
7
6
|
MessageSquare,
|
|
8
7
|
GitBranch,
|
|
9
|
-
GitCommitHorizontal,
|
|
10
8
|
FileDiff,
|
|
11
9
|
Settings,
|
|
12
10
|
FileCode,
|
|
@@ -25,12 +23,10 @@ import { useTabDrag } from "@/hooks/use-tab-drag";
|
|
|
25
23
|
import { DraggableTab } from "./draggable-tab";
|
|
26
24
|
|
|
27
25
|
const TAB_ICONS: Record<TabType, React.ElementType> = {
|
|
28
|
-
projects: FolderOpen,
|
|
29
26
|
terminal: Terminal,
|
|
30
27
|
chat: MessageSquare,
|
|
31
28
|
editor: FileCode,
|
|
32
29
|
"git-graph": GitBranch,
|
|
33
|
-
"git-status": GitCommitHorizontal,
|
|
34
30
|
"git-diff": FileDiff,
|
|
35
31
|
settings: Settings,
|
|
36
32
|
};
|
|
@@ -39,7 +35,6 @@ const NEW_TAB_OPTIONS: { type: TabType; label: string }[] = [
|
|
|
39
35
|
{ type: "terminal", label: "Terminal" },
|
|
40
36
|
{ type: "chat", label: "AI Chat" },
|
|
41
37
|
{ type: "git-graph", label: "Git Graph" },
|
|
42
|
-
{ type: "git-status", label: "Git Status" },
|
|
43
38
|
{ type: "settings", label: "Settings" },
|
|
44
39
|
];
|
|
45
40
|
|
|
@@ -71,7 +66,7 @@ export function TabBar({ panelId }: TabBarProps) {
|
|
|
71
66
|
}, [tabs.length, activeTabId]);
|
|
72
67
|
|
|
73
68
|
function handleNewTab(type: TabType) {
|
|
74
|
-
const needsProject = type === "git-graph" || type === "git-
|
|
69
|
+
const needsProject = type === "git-graph" || type === "git-diff" || type === "terminal" || type === "chat";
|
|
75
70
|
const metadata = needsProject ? { projectName: activeProject?.name } : undefined;
|
|
76
71
|
|
|
77
72
|
usePanelStore.getState().openTab(
|
|
@@ -3,11 +3,6 @@ import { useTabStore, type TabType } from "@/stores/tab-store";
|
|
|
3
3
|
import { Loader2 } from "lucide-react";
|
|
4
4
|
|
|
5
5
|
const TAB_COMPONENTS: Record<TabType, React.LazyExoticComponent<React.ComponentType<{ metadata?: Record<string, unknown>; tabId?: string }>>> = {
|
|
6
|
-
projects: lazy(() =>
|
|
7
|
-
import("@/components/projects/project-list").then((m) => ({
|
|
8
|
-
default: m.ProjectList,
|
|
9
|
-
})),
|
|
10
|
-
),
|
|
11
6
|
terminal: lazy(() =>
|
|
12
7
|
import("@/components/terminal/terminal-tab").then((m) => ({
|
|
13
8
|
default: m.TerminalTab,
|
|
@@ -28,11 +23,6 @@ const TAB_COMPONENTS: Record<TabType, React.LazyExoticComponent<React.ComponentT
|
|
|
28
23
|
default: m.GitGraph,
|
|
29
24
|
})),
|
|
30
25
|
),
|
|
31
|
-
"git-status": lazy(() =>
|
|
32
|
-
import("@/components/git/git-status-panel").then((m) => ({
|
|
33
|
-
default: m.GitStatusPanel,
|
|
34
|
-
})),
|
|
35
|
-
),
|
|
36
26
|
"git-diff": lazy(() =>
|
|
37
27
|
import("@/components/editor/diff-viewer").then((m) => ({
|
|
38
28
|
default: m.DiffViewer,
|
|
@@ -59,7 +59,7 @@ function DialogContent({
|
|
|
59
59
|
<DialogPrimitive.Content
|
|
60
60
|
data-slot="dialog-content"
|
|
61
61
|
className={cn(
|
|
62
|
-
"fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border bg-background p-6 shadow-lg duration-200 outline-none data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95 sm:max-w-lg",
|
|
62
|
+
"fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border bg-background text-foreground p-6 shadow-lg duration-200 outline-none data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95 sm:max-w-lg",
|
|
63
63
|
className
|
|
64
64
|
)}
|
|
65
65
|
{...props}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/** Compute display initials for a project, resolving collisions. */
|
|
2
|
+
export function getProjectInitials(name: string, allNames: string[]): string {
|
|
3
|
+
// Split by common separators, take first char of each word, uppercase
|
|
4
|
+
const words = name.split(/[-_.\s]+/).filter(Boolean);
|
|
5
|
+
const firstChar = (words[0]?.[0] ?? name[0] ?? "?").toUpperCase();
|
|
6
|
+
const twoChars = words.length > 1
|
|
7
|
+
? (firstChar + (words[1]![0] ?? "").toUpperCase())
|
|
8
|
+
: firstChar;
|
|
9
|
+
|
|
10
|
+
// Check if 1-char is unique among all projects
|
|
11
|
+
const others = allNames.filter((n) => n !== name);
|
|
12
|
+
const othersFirstChars = others.map((n) => {
|
|
13
|
+
const w = n.split(/[-_.\s]+/).filter(Boolean);
|
|
14
|
+
return (w[0]?.[0] ?? n[0] ?? "").toUpperCase();
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
if (!othersFirstChars.includes(firstChar)) {
|
|
18
|
+
return firstChar;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// Try 2-char initials
|
|
22
|
+
const othersTwoChars = others.map((n) => {
|
|
23
|
+
const w = n.split(/[-_.\s]+/).filter(Boolean);
|
|
24
|
+
const f = (w[0]?.[0] ?? n[0] ?? "").toUpperCase();
|
|
25
|
+
const s = w.length > 1 ? (w[1]![0] ?? "").toUpperCase() : f;
|
|
26
|
+
return w.length > 1 ? f + s : f;
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
if (!othersTwoChars.includes(twoChars)) {
|
|
30
|
+
return twoChars;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// Fall back to 1-based index
|
|
34
|
+
const idx = allNames.indexOf(name);
|
|
35
|
+
return String(idx >= 0 ? idx + 1 : "?");
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface ProjectAvatar {
|
|
39
|
+
initials: string;
|
|
40
|
+
color: string;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function getProjectAvatar(name: string, allNames: string[], color: string): ProjectAvatar {
|
|
44
|
+
return { initials: getProjectInitials(name, allNames), color };
|
|
45
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
export const PROJECT_PALETTE = [
|
|
2
|
+
'linear-gradient(135deg, #667eea, #764ba2)',
|
|
3
|
+
'linear-gradient(135deg, #f5576c, #f093fb)',
|
|
4
|
+
'linear-gradient(135deg, #4facfe, #00c6ff)',
|
|
5
|
+
'linear-gradient(135deg, #43e97b, #38f9d7)',
|
|
6
|
+
'linear-gradient(135deg, #fa709a, #fee140)',
|
|
7
|
+
'linear-gradient(135deg, #a18cd1, #6a3de8)',
|
|
8
|
+
'linear-gradient(135deg, #fd7043, #ff8a65)',
|
|
9
|
+
'linear-gradient(135deg, #26c6da, #0097a7)',
|
|
10
|
+
'linear-gradient(135deg, #ab47bc, #7b1fa2)',
|
|
11
|
+
'linear-gradient(135deg, #ef5350, #b71c1c)',
|
|
12
|
+
'linear-gradient(135deg, #1976d2, #42a5f5)',
|
|
13
|
+
'linear-gradient(135deg, #2e7d32, #66bb6a)',
|
|
14
|
+
] as const;
|
|
15
|
+
|
|
16
|
+
export function resolveProjectColor(color: string | undefined, index: number): string {
|
|
17
|
+
return color ?? PROJECT_PALETTE[index % PROJECT_PALETTE.length]!;
|
|
18
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { useEffect, useState } from "react";
|
|
2
|
+
import { useSettingsStore } from "@/stores/settings-store";
|
|
3
|
+
|
|
4
|
+
/** Resolves the current app theme to a Monaco editor theme name. */
|
|
5
|
+
export function useMonacoTheme(): string {
|
|
6
|
+
const theme = useSettingsStore((s) => s.theme);
|
|
7
|
+
|
|
8
|
+
const resolve = () => {
|
|
9
|
+
if (theme === "dark") return "vs-dark";
|
|
10
|
+
if (theme === "light") return "light";
|
|
11
|
+
// system
|
|
12
|
+
return window.matchMedia("(prefers-color-scheme: dark)").matches ? "vs-dark" : "light";
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
const [monacoTheme, setMonacoTheme] = useState(resolve);
|
|
16
|
+
|
|
17
|
+
useEffect(() => {
|
|
18
|
+
setMonacoTheme(resolve());
|
|
19
|
+
|
|
20
|
+
if (theme === "system") {
|
|
21
|
+
const mq = window.matchMedia("(prefers-color-scheme: dark)");
|
|
22
|
+
const handler = () => setMonacoTheme(mq.matches ? "vs-dark" : "light");
|
|
23
|
+
mq.addEventListener("change", handler);
|
|
24
|
+
return () => mq.removeEventListener("change", handler);
|
|
25
|
+
}
|
|
26
|
+
}, [theme]);
|
|
27
|
+
|
|
28
|
+
return monacoTheme;
|
|
29
|
+
}
|
|
@@ -16,7 +16,10 @@ import {
|
|
|
16
16
|
} from "./panel-utils";
|
|
17
17
|
|
|
18
18
|
/** Tab types that can only have 1 instance per project */
|
|
19
|
-
const SINGLETON_TYPES = new Set<TabType>(["git-
|
|
19
|
+
const SINGLETON_TYPES = new Set<TabType>(["git-graph", "settings"]);
|
|
20
|
+
|
|
21
|
+
/** Tab types removed in a prior version — filter them out when loading persisted state */
|
|
22
|
+
const OBSOLETE_TAB_TYPES = new Set(["projects", "git-status"]);
|
|
20
23
|
|
|
21
24
|
function generateTabId(): string {
|
|
22
25
|
return `tab-${randomId()}`;
|
|
@@ -38,6 +41,10 @@ export interface PanelStore {
|
|
|
38
41
|
focusedPanelId: string;
|
|
39
42
|
currentProject: string | null;
|
|
40
43
|
|
|
44
|
+
/** Keep-alive: per-project grid snapshots (for hidden workspaces) */
|
|
45
|
+
projectGrids: Record<string, string[][]>;
|
|
46
|
+
projectFocused: Record<string, string>;
|
|
47
|
+
|
|
41
48
|
// Project lifecycle
|
|
42
49
|
switchProject: (projectName: string) => void;
|
|
43
50
|
|
|
@@ -70,9 +77,16 @@ function defaultLayout(): { panels: Record<string, Panel>; grid: string[][]; foc
|
|
|
70
77
|
// Store
|
|
71
78
|
// ---------------------------------------------------------------------------
|
|
72
79
|
export const usePanelStore = create<PanelStore>()((set, get) => {
|
|
80
|
+
/** Save only the active project's panels to localStorage */
|
|
73
81
|
function persist() {
|
|
74
82
|
const { currentProject, panels, grid, focusedPanelId } = get();
|
|
75
|
-
if (currentProject)
|
|
83
|
+
if (!currentProject) return;
|
|
84
|
+
const panelIds = new Set(grid.flat());
|
|
85
|
+
const projectPanels: Record<string, Panel> = {};
|
|
86
|
+
for (const [id, p] of Object.entries(panels)) {
|
|
87
|
+
if (panelIds.has(id)) projectPanels[id] = p;
|
|
88
|
+
}
|
|
89
|
+
savePanelLayout(currentProject, { panels: projectPanels, grid, focusedPanelId });
|
|
76
90
|
}
|
|
77
91
|
|
|
78
92
|
function findPanel(tabId: string): Panel | undefined {
|
|
@@ -86,25 +100,98 @@ export const usePanelStore = create<PanelStore>()((set, get) => {
|
|
|
86
100
|
return {
|
|
87
101
|
...defaultLayout(),
|
|
88
102
|
currentProject: null,
|
|
103
|
+
projectGrids: {},
|
|
104
|
+
projectFocused: {},
|
|
89
105
|
|
|
90
106
|
switchProject: (projectName) => {
|
|
91
|
-
const { currentProject } = get();
|
|
92
|
-
|
|
107
|
+
const { currentProject, panels, grid, focusedPanelId, projectGrids, projectFocused } = get();
|
|
108
|
+
|
|
109
|
+
// No-op if same project
|
|
110
|
+
if (currentProject === projectName) return;
|
|
111
|
+
|
|
112
|
+
// Snapshot current project's state
|
|
113
|
+
const newProjectGrids = { ...projectGrids };
|
|
114
|
+
const newProjectFocused = { ...projectFocused };
|
|
115
|
+
|
|
116
|
+
if (currentProject) {
|
|
117
|
+
newProjectGrids[currentProject] = grid;
|
|
118
|
+
newProjectFocused[currentProject] = focusedPanelId;
|
|
119
|
+
// Persist to localStorage
|
|
120
|
+
const panelIds = new Set(grid.flat());
|
|
121
|
+
const currentPanels: Record<string, Panel> = {};
|
|
122
|
+
for (const [id, p] of Object.entries(panels)) {
|
|
123
|
+
if (panelIds.has(id)) currentPanels[id] = p;
|
|
124
|
+
}
|
|
125
|
+
savePanelLayout(currentProject, { panels: currentPanels, grid, focusedPanelId });
|
|
126
|
+
}
|
|
93
127
|
|
|
128
|
+
// Already in memory → restore from snapshot (no localStorage read)
|
|
129
|
+
if (newProjectGrids[projectName]) {
|
|
130
|
+
const restoredGrid = newProjectGrids[projectName]!;
|
|
131
|
+
const restoredFocused = newProjectFocused[projectName] ?? restoredGrid[0]?.[0] ?? "";
|
|
132
|
+
set({
|
|
133
|
+
currentProject: projectName,
|
|
134
|
+
grid: restoredGrid,
|
|
135
|
+
focusedPanelId: restoredFocused,
|
|
136
|
+
projectGrids: newProjectGrids,
|
|
137
|
+
projectFocused: newProjectFocused,
|
|
138
|
+
});
|
|
139
|
+
return;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// Load from localStorage
|
|
94
143
|
const loaded = loadPanelLayout(projectName);
|
|
95
144
|
if (loaded && Object.keys(loaded.panels).length > 0) {
|
|
96
|
-
|
|
145
|
+
// Migrate: remove obsolete tab types
|
|
146
|
+
const migratedPanels: typeof loaded.panels = {};
|
|
147
|
+
for (const [pid, panel] of Object.entries(loaded.panels)) {
|
|
148
|
+
const filteredTabs = panel.tabs.filter((t) => !OBSOLETE_TAB_TYPES.has(t.type));
|
|
149
|
+
const filteredHistory = panel.tabHistory.filter(
|
|
150
|
+
(id) => filteredTabs.some((t) => t.id === id),
|
|
151
|
+
);
|
|
152
|
+
const activeTabId = panel.activeTabId && filteredTabs.some((t) => t.id === panel.activeTabId)
|
|
153
|
+
? panel.activeTabId
|
|
154
|
+
: (filteredHistory[filteredHistory.length - 1] ?? filteredTabs[0]?.id ?? null);
|
|
155
|
+
migratedPanels[pid] = { ...panel, tabs: filteredTabs, tabHistory: filteredHistory, activeTabId };
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// Merge into flat panels map (keep-alive: old panels stay)
|
|
159
|
+
const mergedPanels = { ...panels, ...migratedPanels };
|
|
160
|
+
newProjectGrids[projectName] = loaded.grid;
|
|
161
|
+
newProjectFocused[projectName] = loaded.focusedPanelId;
|
|
162
|
+
set({
|
|
163
|
+
currentProject: projectName,
|
|
164
|
+
panels: mergedPanels,
|
|
165
|
+
grid: loaded.grid,
|
|
166
|
+
focusedPanelId: loaded.focusedPanelId,
|
|
167
|
+
projectGrids: newProjectGrids,
|
|
168
|
+
projectFocused: newProjectFocused,
|
|
169
|
+
});
|
|
97
170
|
} else {
|
|
171
|
+
// Create default layout for new project
|
|
98
172
|
const p = createPanel();
|
|
99
173
|
const defaultTab: Tab = {
|
|
100
|
-
id: generateTabId(), type: "
|
|
174
|
+
id: generateTabId(), type: "chat", title: "AI Chat", projectId: projectName, closable: true,
|
|
175
|
+
metadata: { projectName },
|
|
101
176
|
};
|
|
102
177
|
p.tabs = [defaultTab];
|
|
103
178
|
p.activeTabId = defaultTab.id;
|
|
104
179
|
p.tabHistory = [defaultTab.id];
|
|
105
|
-
const
|
|
106
|
-
|
|
107
|
-
|
|
180
|
+
const newGrid = [[p.id]];
|
|
181
|
+
|
|
182
|
+
// Merge into flat panels map
|
|
183
|
+
const mergedPanels = { ...panels, [p.id]: p };
|
|
184
|
+
newProjectGrids[projectName] = newGrid;
|
|
185
|
+
newProjectFocused[projectName] = p.id;
|
|
186
|
+
savePanelLayout(projectName, { panels: { [p.id]: p }, grid: newGrid, focusedPanelId: p.id });
|
|
187
|
+
set({
|
|
188
|
+
currentProject: projectName,
|
|
189
|
+
panels: mergedPanels,
|
|
190
|
+
grid: newGrid,
|
|
191
|
+
focusedPanelId: p.id,
|
|
192
|
+
projectGrids: newProjectGrids,
|
|
193
|
+
projectFocused: newProjectFocused,
|
|
194
|
+
});
|
|
108
195
|
}
|
|
109
196
|
},
|
|
110
197
|
|