@hienlh/ppm 0.1.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/.claude/agent-memory/tester/MEMORY.md +3 -0
- package/.claude/agent-memory/tester/project-ppm-test-conventions.md +32 -0
- package/.env.example +1 -0
- package/.github/workflows/release.yml +46 -0
- package/README.md +349 -0
- package/bun.lock +1217 -0
- package/components.json +21 -0
- package/docs/code-standards.md +574 -0
- package/docs/codebase-summary.md +294 -0
- package/docs/deployment-guide.md +631 -0
- package/docs/design-guidelines.md +661 -0
- package/docs/project-overview-pdr.md +142 -0
- package/docs/project-roadmap.md +400 -0
- package/docs/system-architecture.md +459 -0
- package/package.json +68 -0
- package/plans/260314-2009-ppm-implementation/phase-01-project-skeleton.md +81 -0
- package/plans/260314-2009-ppm-implementation/phase-02-backend-core.md +148 -0
- package/plans/260314-2009-ppm-implementation/phase-03-frontend-shell.md +256 -0
- package/plans/260314-2009-ppm-implementation/phase-04-file-explorer-editor.md +120 -0
- package/plans/260314-2009-ppm-implementation/phase-05-web-terminal.md +174 -0
- package/plans/260314-2009-ppm-implementation/phase-06-git-integration.md +244 -0
- package/plans/260314-2009-ppm-implementation/phase-07-ai-chat.md +242 -0
- package/plans/260314-2009-ppm-implementation/phase-08-cli-commands.md +143 -0
- package/plans/260314-2009-ppm-implementation/phase-09-pwa-build-deploy.md +209 -0
- package/plans/260314-2009-ppm-implementation/phase-10-testing.md +311 -0
- package/plans/260314-2009-ppm-implementation/plan.md +202 -0
- package/plans/260315-0356-project-scoped-api-refactor/phase-01-backend-project-router.md +145 -0
- package/plans/260315-0356-project-scoped-api-refactor/phase-02-frontend-api-migration.md +107 -0
- package/plans/260315-0356-project-scoped-api-refactor/phase-03-per-project-tabs.md +100 -0
- package/plans/260315-0356-project-scoped-api-refactor/phase-04-websocket-migration.md +66 -0
- package/plans/260315-0356-project-scoped-api-refactor/plan.md +87 -0
- package/plans/reports/brainstorm-260314-1938-final-techstack.md +342 -0
- package/plans/reports/docs-manager-260315-1314-documentation-creation.md +386 -0
- package/plans/reports/fullstack-developer-260314-2252-phase-02-backend-core.md +57 -0
- package/plans/reports/fullstack-developer-260314-2253-phase-03-frontend-shell.md +70 -0
- package/plans/reports/fullstack-developer-260314-2300-phase-04-05-file-api-terminal-ws.md +49 -0
- package/plans/reports/fullstack-developer-260314-2300-phase-04-05-file-explorer-editor-terminal.md +52 -0
- package/plans/reports/fullstack-developer-260314-2307-ai-chat-phase7.md +58 -0
- package/plans/reports/fullstack-developer-260314-2307-phase-06-git-integration.md +33 -0
- package/plans/reports/research-260314-1911-ppm-tech-stack.md +318 -0
- package/plans/reports/research-260314-1930-claude-code-integration.md +293 -0
- package/plans/reports/researcher-260314-2232-node-pty-bun-crash-analysis.md +305 -0
- package/plans/reports/researcher-260314-2232-ui-style.md +942 -0
- package/plans/reports/researcher-260315-0300-opcode-claude-interaction.md +745 -0
- package/plans/reports/researcher-260315-0303-opcode-deep-analysis.md +742 -0
- package/plans/reports/researcher-260315-0305-claude-agent-sdk-github-research.md +423 -0
- package/plans/reports/tester-260314-2053-initial-test-suite.md +81 -0
- package/ppm.example.yaml +14 -0
- package/repomix-output.xml +23745 -0
- package/scripts/build.ts +13 -0
- package/src/cli/commands/chat-cmd.ts +259 -0
- package/src/cli/commands/config-cmd.ts +121 -0
- package/src/cli/commands/git-cmd.ts +315 -0
- package/src/cli/commands/init.ts +57 -0
- package/src/cli/commands/open.ts +19 -0
- package/src/cli/commands/projects.ts +100 -0
- package/src/cli/commands/start.ts +3 -0
- package/src/cli/commands/stop.ts +33 -0
- package/src/cli/utils/project-resolver.ts +27 -0
- package/src/index.ts +59 -0
- package/src/providers/claude-agent-sdk.ts +499 -0
- package/src/providers/claude-binary-finder.ts +256 -0
- package/src/providers/claude-code-cli.ts +413 -0
- package/src/providers/claude-process-registry.ts +106 -0
- package/src/providers/mock-provider.ts +171 -0
- package/src/providers/provider.interface.ts +10 -0
- package/src/providers/registry.ts +45 -0
- package/src/server/helpers/resolve-project.ts +22 -0
- package/src/server/index.ts +181 -0
- package/src/server/middleware/auth.ts +30 -0
- package/src/server/routes/chat.ts +153 -0
- package/src/server/routes/files.ts +168 -0
- package/src/server/routes/git.ts +261 -0
- package/src/server/routes/project-scoped.ts +27 -0
- package/src/server/routes/projects.ts +57 -0
- package/src/server/routes/static.ts +26 -0
- package/src/server/ws/chat.ts +130 -0
- package/src/server/ws/terminal.ts +89 -0
- package/src/services/chat.service.ts +110 -0
- package/src/services/claude-usage.service.ts +113 -0
- package/src/services/config.service.ts +90 -0
- package/src/services/file.service.ts +261 -0
- package/src/services/git-dirs.service.ts +112 -0
- package/src/services/git.service.ts +372 -0
- package/src/services/project.service.ts +107 -0
- package/src/services/slash-items.service.ts +184 -0
- package/src/services/terminal.service.ts +212 -0
- package/src/types/api.ts +37 -0
- package/src/types/chat.ts +92 -0
- package/src/types/config.ts +41 -0
- package/src/types/git.ts +50 -0
- package/src/types/project.ts +18 -0
- package/src/types/terminal.ts +20 -0
- package/src/web/app.tsx +168 -0
- package/src/web/components/auth/login-screen.tsx +88 -0
- package/src/web/components/chat/attachment-chips.tsx +55 -0
- package/src/web/components/chat/chat-placeholder.tsx +10 -0
- package/src/web/components/chat/chat-tab.tsx +301 -0
- package/src/web/components/chat/file-picker.tsx +126 -0
- package/src/web/components/chat/message-input.tsx +420 -0
- package/src/web/components/chat/message-list.tsx +838 -0
- package/src/web/components/chat/session-picker.tsx +139 -0
- package/src/web/components/chat/slash-command-picker.tsx +135 -0
- package/src/web/components/chat/usage-badge.tsx +186 -0
- package/src/web/components/editor/code-editor.tsx +329 -0
- package/src/web/components/editor/diff-viewer.tsx +276 -0
- package/src/web/components/editor/editor-placeholder.tsx +10 -0
- package/src/web/components/explorer/file-actions.tsx +191 -0
- package/src/web/components/explorer/file-tree.tsx +298 -0
- package/src/web/components/git/git-graph.tsx +727 -0
- package/src/web/components/git/git-placeholder.tsx +55 -0
- package/src/web/components/git/git-status-panel.tsx +850 -0
- package/src/web/components/layout/mobile-drawer.tsx +137 -0
- package/src/web/components/layout/mobile-nav.tsx +103 -0
- package/src/web/components/layout/sidebar.tsx +90 -0
- package/src/web/components/layout/tab-bar.tsx +152 -0
- package/src/web/components/layout/tab-content.tsx +85 -0
- package/src/web/components/projects/dir-suggest.tsx +152 -0
- package/src/web/components/projects/project-list.tsx +187 -0
- package/src/web/components/settings/settings-tab.tsx +57 -0
- package/src/web/components/terminal/terminal-placeholder.tsx +10 -0
- package/src/web/components/terminal/terminal-tab.tsx +133 -0
- package/src/web/components/ui/button.tsx +64 -0
- package/src/web/components/ui/context-menu.tsx +250 -0
- package/src/web/components/ui/dialog.tsx +156 -0
- package/src/web/components/ui/dropdown-menu.tsx +257 -0
- package/src/web/components/ui/input.tsx +21 -0
- package/src/web/components/ui/scroll-area.tsx +56 -0
- package/src/web/components/ui/separator.tsx +26 -0
- package/src/web/components/ui/sonner.tsx +40 -0
- package/src/web/components/ui/tabs.tsx +91 -0
- package/src/web/components/ui/tooltip.tsx +57 -0
- package/src/web/hooks/use-chat.ts +420 -0
- package/src/web/hooks/use-terminal.ts +182 -0
- package/src/web/hooks/use-url-sync.ts +66 -0
- package/src/web/hooks/use-websocket.ts +48 -0
- package/src/web/index.html +16 -0
- package/src/web/lib/api-client.ts +90 -0
- package/src/web/lib/file-support.ts +68 -0
- package/src/web/lib/utils.ts +6 -0
- package/src/web/lib/ws-client.ts +100 -0
- package/src/web/main.tsx +10 -0
- package/src/web/public/icon-192.svg +5 -0
- package/src/web/public/icon-512.svg +5 -0
- package/src/web/stores/file-store.ts +81 -0
- package/src/web/stores/project-store.ts +50 -0
- package/src/web/stores/settings-store.ts +65 -0
- package/src/web/stores/tab-store.ts +187 -0
- package/src/web/styles/globals.css +227 -0
- package/src/web/vite-env.d.ts +1 -0
- package/tests/integration/api/chat-routes.test.ts +95 -0
- package/tests/integration/claude-agent-sdk-integration.test.ts +228 -0
- package/tests/integration/ws/chat-websocket.test.ts +312 -0
- package/tests/test-setup.ts +5 -0
- package/tests/unit/providers/claude-agent-sdk.test.ts +339 -0
- package/tests/unit/providers/mock-provider.test.ts +143 -0
- package/tests/unit/services/chat-service.test.ts +100 -0
- package/tsconfig.json +32 -0
- package/vite.config.ts +62 -0
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
import {
|
|
2
|
+
FolderOpen,
|
|
3
|
+
Terminal,
|
|
4
|
+
MessageSquare,
|
|
5
|
+
GitBranch,
|
|
6
|
+
GitCommitHorizontal,
|
|
7
|
+
FileDiff,
|
|
8
|
+
Settings,
|
|
9
|
+
X,
|
|
10
|
+
FileCode,
|
|
11
|
+
} from "lucide-react";
|
|
12
|
+
import { useProjectStore } from "@/stores/project-store";
|
|
13
|
+
import { useTabStore, type TabType } from "@/stores/tab-store";
|
|
14
|
+
import { cn } from "@/lib/utils";
|
|
15
|
+
import { Separator } from "@/components/ui/separator";
|
|
16
|
+
import { FileTree } from "@/components/explorer/file-tree";
|
|
17
|
+
|
|
18
|
+
interface MobileDrawerProps {
|
|
19
|
+
isOpen: boolean;
|
|
20
|
+
onClose: () => void;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const TAB_ICONS: Record<TabType, React.ElementType> = {
|
|
24
|
+
projects: FolderOpen,
|
|
25
|
+
terminal: Terminal,
|
|
26
|
+
chat: MessageSquare,
|
|
27
|
+
editor: FileCode,
|
|
28
|
+
"git-graph": GitBranch,
|
|
29
|
+
"git-status": GitCommitHorizontal,
|
|
30
|
+
"git-diff": FileDiff,
|
|
31
|
+
settings: Settings,
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
const NEW_TAB_OPTIONS: { type: TabType; label: string }[] = [
|
|
35
|
+
{ type: "projects", label: "Projects" },
|
|
36
|
+
{ type: "terminal", label: "Terminal" },
|
|
37
|
+
{ type: "chat", label: "AI Chat" },
|
|
38
|
+
{ type: "git-status", label: "Git Status" },
|
|
39
|
+
{ type: "git-graph", label: "Git Graph" },
|
|
40
|
+
{ type: "settings", label: "Settings" },
|
|
41
|
+
];
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Mobile drawer overlay — opens from bottom-left menu button.
|
|
45
|
+
* Top: file tree of current project.
|
|
46
|
+
* Bottom: new tab options.
|
|
47
|
+
*/
|
|
48
|
+
export function MobileDrawer({ isOpen, onClose }: MobileDrawerProps) {
|
|
49
|
+
const activeProject = useProjectStore((s) => s.activeProject);
|
|
50
|
+
const openTab = useTabStore((s) => s.openTab);
|
|
51
|
+
|
|
52
|
+
function handleNewTab(type: TabType) {
|
|
53
|
+
const needsProject =
|
|
54
|
+
type === "git-graph" || type === "git-status" || type === "git-diff" || type === "terminal" || type === "chat";
|
|
55
|
+
const metadata = needsProject
|
|
56
|
+
? { projectName: activeProject?.name }
|
|
57
|
+
: undefined;
|
|
58
|
+
const label = NEW_TAB_OPTIONS.find((o) => o.type === type)?.label ?? type;
|
|
59
|
+
openTab({ type, title: label, metadata, projectId: activeProject?.name ?? null, closable: type !== "projects" });
|
|
60
|
+
onClose();
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
return (
|
|
64
|
+
<div
|
|
65
|
+
className={cn(
|
|
66
|
+
"fixed inset-0 z-50 md:hidden transition-opacity duration-200",
|
|
67
|
+
isOpen
|
|
68
|
+
? "opacity-100"
|
|
69
|
+
: "opacity-0 pointer-events-none",
|
|
70
|
+
)}
|
|
71
|
+
>
|
|
72
|
+
{/* Backdrop */}
|
|
73
|
+
<div
|
|
74
|
+
className="absolute inset-0 bg-black/50"
|
|
75
|
+
onClick={onClose}
|
|
76
|
+
aria-label="Close drawer"
|
|
77
|
+
/>
|
|
78
|
+
|
|
79
|
+
{/* Drawer panel */}
|
|
80
|
+
<div
|
|
81
|
+
className={cn(
|
|
82
|
+
"fixed left-0 top-0 bottom-0 w-[280px] bg-background border-r border-border",
|
|
83
|
+
"z-50 flex flex-col transition-transform duration-300 ease-out",
|
|
84
|
+
isOpen ? "translate-x-0" : "-translate-x-full",
|
|
85
|
+
)}
|
|
86
|
+
>
|
|
87
|
+
{/* Header */}
|
|
88
|
+
<div className="flex items-center justify-between px-4 py-3 border-b border-border">
|
|
89
|
+
<div className="flex items-center gap-2">
|
|
90
|
+
<FolderOpen className="size-4 text-primary" />
|
|
91
|
+
<span className="text-sm font-semibold truncate">
|
|
92
|
+
{activeProject?.name ?? "PPM"}
|
|
93
|
+
</span>
|
|
94
|
+
</div>
|
|
95
|
+
<button
|
|
96
|
+
onClick={onClose}
|
|
97
|
+
className="flex items-center justify-center size-8 rounded-md hover:bg-surface-elevated transition-colors"
|
|
98
|
+
>
|
|
99
|
+
<X className="size-4" />
|
|
100
|
+
</button>
|
|
101
|
+
</div>
|
|
102
|
+
|
|
103
|
+
{/* File tree — takes remaining space */}
|
|
104
|
+
<div className="flex-1 overflow-y-auto">
|
|
105
|
+
{activeProject ? (
|
|
106
|
+
<FileTree onFileOpen={onClose} />
|
|
107
|
+
) : (
|
|
108
|
+
<p className="px-4 py-3 text-xs text-text-secondary">
|
|
109
|
+
No project selected.
|
|
110
|
+
</p>
|
|
111
|
+
)}
|
|
112
|
+
</div>
|
|
113
|
+
|
|
114
|
+
{/* New tab options — pinned at bottom */}
|
|
115
|
+
<Separator />
|
|
116
|
+
<div className="px-2 py-2 space-y-0.5">
|
|
117
|
+
<p className="px-2 pb-1 text-xs font-semibold text-text-secondary uppercase tracking-wider">
|
|
118
|
+
New Tab
|
|
119
|
+
</p>
|
|
120
|
+
{NEW_TAB_OPTIONS.map((opt) => {
|
|
121
|
+
const Icon = TAB_ICONS[opt.type];
|
|
122
|
+
return (
|
|
123
|
+
<button
|
|
124
|
+
key={opt.type}
|
|
125
|
+
onClick={() => handleNewTab(opt.type)}
|
|
126
|
+
className="w-full flex items-center gap-2 px-3 py-2 rounded-md text-sm text-text-secondary hover:bg-surface-elevated hover:text-foreground transition-colors min-h-[40px]"
|
|
127
|
+
>
|
|
128
|
+
<Icon className="size-4 shrink-0" />
|
|
129
|
+
<span>{opt.label}</span>
|
|
130
|
+
</button>
|
|
131
|
+
);
|
|
132
|
+
})}
|
|
133
|
+
</div>
|
|
134
|
+
</div>
|
|
135
|
+
</div>
|
|
136
|
+
);
|
|
137
|
+
}
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import {
|
|
2
|
+
FolderOpen,
|
|
3
|
+
Terminal,
|
|
4
|
+
MessageSquare,
|
|
5
|
+
GitBranch,
|
|
6
|
+
GitCommitHorizontal,
|
|
7
|
+
FileDiff,
|
|
8
|
+
FileCode,
|
|
9
|
+
Settings,
|
|
10
|
+
Menu,
|
|
11
|
+
X,
|
|
12
|
+
} from "lucide-react";
|
|
13
|
+
import { useTabStore, type TabType } from "@/stores/tab-store";
|
|
14
|
+
import { cn } from "@/lib/utils";
|
|
15
|
+
import { useEffect, useRef } from "react";
|
|
16
|
+
|
|
17
|
+
const TAB_ICONS: Record<TabType, React.ElementType> = {
|
|
18
|
+
projects: FolderOpen,
|
|
19
|
+
terminal: Terminal,
|
|
20
|
+
chat: MessageSquare,
|
|
21
|
+
editor: FileCode,
|
|
22
|
+
"git-graph": GitBranch,
|
|
23
|
+
"git-status": GitCommitHorizontal,
|
|
24
|
+
"git-diff": FileDiff,
|
|
25
|
+
settings: Settings,
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
interface MobileNavProps {
|
|
29
|
+
onMenuPress: () => void;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Mobile bottom tab bar — scrollable tabs with menu button on the left.
|
|
34
|
+
*/
|
|
35
|
+
export function MobileNav({ onMenuPress }: MobileNavProps) {
|
|
36
|
+
const { tabs, activeTabId, setActiveTab, closeTab } = useTabStore();
|
|
37
|
+
const tabRefs = useRef<Map<string, HTMLButtonElement>>(new Map());
|
|
38
|
+
const prevTabCount = useRef(tabs.length);
|
|
39
|
+
|
|
40
|
+
// Auto-scroll to new tab when added
|
|
41
|
+
useEffect(() => {
|
|
42
|
+
if (tabs.length > prevTabCount.current && activeTabId) {
|
|
43
|
+
const el = tabRefs.current.get(activeTabId);
|
|
44
|
+
if (el) {
|
|
45
|
+
el.scrollIntoView({ behavior: "smooth", block: "nearest", inline: "nearest" });
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
prevTabCount.current = tabs.length;
|
|
49
|
+
}, [tabs.length, activeTabId]);
|
|
50
|
+
|
|
51
|
+
return (
|
|
52
|
+
<nav className="fixed bottom-0 left-0 right-0 md:hidden bg-background border-t border-border z-40">
|
|
53
|
+
<div className="flex items-center h-12">
|
|
54
|
+
{/* Menu button — opens drawer with file tree + new tab options */}
|
|
55
|
+
<button
|
|
56
|
+
onClick={onMenuPress}
|
|
57
|
+
className="flex items-center justify-center size-12 shrink-0 text-text-secondary border-r border-border"
|
|
58
|
+
>
|
|
59
|
+
<Menu className="size-5" />
|
|
60
|
+
</button>
|
|
61
|
+
|
|
62
|
+
<div className="flex-1 flex items-center h-12 overflow-x-auto">
|
|
63
|
+
{tabs.map((tab) => {
|
|
64
|
+
const Icon = TAB_ICONS[tab.type];
|
|
65
|
+
const isActive = tab.id === activeTabId;
|
|
66
|
+
return (
|
|
67
|
+
<button
|
|
68
|
+
key={tab.id}
|
|
69
|
+
ref={(el) => {
|
|
70
|
+
if (el) tabRefs.current.set(tab.id, el);
|
|
71
|
+
else tabRefs.current.delete(tab.id);
|
|
72
|
+
}}
|
|
73
|
+
onClick={() => setActiveTab(tab.id)}
|
|
74
|
+
className={cn(
|
|
75
|
+
"flex items-center gap-1 px-3 h-12 whitespace-nowrap text-xs shrink-0 border-t-2 transition-colors",
|
|
76
|
+
isActive
|
|
77
|
+
? "border-primary bg-surface text-primary"
|
|
78
|
+
: "border-transparent text-text-secondary",
|
|
79
|
+
)}
|
|
80
|
+
>
|
|
81
|
+
<Icon className="size-4" />
|
|
82
|
+
<span className="max-w-[80px] truncate">{tab.title}</span>
|
|
83
|
+
{tab.closable && (
|
|
84
|
+
<span
|
|
85
|
+
role="button"
|
|
86
|
+
tabIndex={0}
|
|
87
|
+
onClick={(e) => {
|
|
88
|
+
e.stopPropagation();
|
|
89
|
+
closeTab(tab.id);
|
|
90
|
+
}}
|
|
91
|
+
className="ml-0.5 p-0.5 rounded hover:bg-surface-elevated"
|
|
92
|
+
>
|
|
93
|
+
<X className="size-3" />
|
|
94
|
+
</span>
|
|
95
|
+
)}
|
|
96
|
+
</button>
|
|
97
|
+
);
|
|
98
|
+
})}
|
|
99
|
+
</div>
|
|
100
|
+
</div>
|
|
101
|
+
</nav>
|
|
102
|
+
);
|
|
103
|
+
}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import { FolderOpen, ChevronRight, ChevronDown } from "lucide-react";
|
|
2
|
+
import { useProjectStore } from "@/stores/project-store";
|
|
3
|
+
import { useTabStore } from "@/stores/tab-store";
|
|
4
|
+
import { cn } from "@/lib/utils";
|
|
5
|
+
import { FileTree } from "@/components/explorer/file-tree";
|
|
6
|
+
import { Separator } from "@/components/ui/separator";
|
|
7
|
+
import { useState } from "react";
|
|
8
|
+
|
|
9
|
+
export function Sidebar() {
|
|
10
|
+
const { projects, activeProject, setActiveProject, loading } =
|
|
11
|
+
useProjectStore();
|
|
12
|
+
const openTab = useTabStore((s) => s.openTab);
|
|
13
|
+
const [projectsExpanded, setProjectsExpanded] = useState(true);
|
|
14
|
+
|
|
15
|
+
function handleProjectClick(project: (typeof projects)[number]) {
|
|
16
|
+
setActiveProject(project);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
return (
|
|
20
|
+
<aside className="hidden md:flex flex-col w-[280px] min-w-[280px] bg-background border-r border-border overflow-y-auto">
|
|
21
|
+
{/* Projects section header */}
|
|
22
|
+
<button
|
|
23
|
+
onClick={() => setProjectsExpanded(!projectsExpanded)}
|
|
24
|
+
className="flex items-center gap-2 px-4 py-3 border-b border-border hover:bg-surface-elevated transition-colors"
|
|
25
|
+
>
|
|
26
|
+
{projectsExpanded ? (
|
|
27
|
+
<ChevronDown className="size-3.5 text-text-subtle" />
|
|
28
|
+
) : (
|
|
29
|
+
<ChevronRight className="size-3.5 text-text-subtle" />
|
|
30
|
+
)}
|
|
31
|
+
<FolderOpen className="size-4 text-primary" />
|
|
32
|
+
<span className="text-sm font-semibold">Projects</span>
|
|
33
|
+
</button>
|
|
34
|
+
|
|
35
|
+
{/* Projects list (collapsible) */}
|
|
36
|
+
{projectsExpanded && (
|
|
37
|
+
<div className="p-2 space-y-1">
|
|
38
|
+
{loading && (
|
|
39
|
+
<p className="px-2 py-1 text-xs text-text-secondary">Loading...</p>
|
|
40
|
+
)}
|
|
41
|
+
|
|
42
|
+
{!loading && projects.length === 0 && (
|
|
43
|
+
<p className="px-2 py-1 text-xs text-text-secondary">
|
|
44
|
+
No projects found. Register one via CLI.
|
|
45
|
+
</p>
|
|
46
|
+
)}
|
|
47
|
+
|
|
48
|
+
{projects.map((project) => (
|
|
49
|
+
<button
|
|
50
|
+
key={project.name}
|
|
51
|
+
onClick={() => handleProjectClick(project)}
|
|
52
|
+
className={cn(
|
|
53
|
+
"w-full flex items-center gap-2 px-3 py-2 rounded-md text-sm transition-colors text-left",
|
|
54
|
+
"min-h-[44px]",
|
|
55
|
+
activeProject?.name === project.name
|
|
56
|
+
? "bg-surface text-foreground"
|
|
57
|
+
: "text-text-secondary hover:bg-surface-elevated hover:text-foreground",
|
|
58
|
+
)}
|
|
59
|
+
>
|
|
60
|
+
<FolderOpen className="size-4 shrink-0" />
|
|
61
|
+
<div className="flex-1 min-w-0">
|
|
62
|
+
<p className="truncate font-medium">{project.name}</p>
|
|
63
|
+
<p className="truncate text-xs text-text-subtle">
|
|
64
|
+
{project.path}
|
|
65
|
+
</p>
|
|
66
|
+
</div>
|
|
67
|
+
{project.branch && (
|
|
68
|
+
<span className="text-xs text-primary shrink-0">
|
|
69
|
+
{project.branch}
|
|
70
|
+
</span>
|
|
71
|
+
)}
|
|
72
|
+
<ChevronRight className="size-3 text-text-subtle shrink-0" />
|
|
73
|
+
</button>
|
|
74
|
+
))}
|
|
75
|
+
</div>
|
|
76
|
+
)}
|
|
77
|
+
|
|
78
|
+
{/* File tree section */}
|
|
79
|
+
{activeProject && (
|
|
80
|
+
<>
|
|
81
|
+
<Separator />
|
|
82
|
+
<div className="flex items-center gap-2 px-4 py-2 text-xs font-semibold text-text-secondary uppercase tracking-wider">
|
|
83
|
+
Files
|
|
84
|
+
</div>
|
|
85
|
+
<FileTree />
|
|
86
|
+
</>
|
|
87
|
+
)}
|
|
88
|
+
</aside>
|
|
89
|
+
);
|
|
90
|
+
}
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
import { useEffect, useRef } from "react";
|
|
2
|
+
import {
|
|
3
|
+
X,
|
|
4
|
+
Plus,
|
|
5
|
+
FolderOpen,
|
|
6
|
+
Terminal,
|
|
7
|
+
MessageSquare,
|
|
8
|
+
GitBranch,
|
|
9
|
+
GitCommitHorizontal,
|
|
10
|
+
FileDiff,
|
|
11
|
+
Settings,
|
|
12
|
+
FileCode,
|
|
13
|
+
} from "lucide-react";
|
|
14
|
+
import {
|
|
15
|
+
DropdownMenu,
|
|
16
|
+
DropdownMenuContent,
|
|
17
|
+
DropdownMenuItem,
|
|
18
|
+
DropdownMenuTrigger,
|
|
19
|
+
} from "@/components/ui/dropdown-menu";
|
|
20
|
+
import { ScrollArea, ScrollBar } from "@/components/ui/scroll-area";
|
|
21
|
+
import { useTabStore, type TabType } from "@/stores/tab-store";
|
|
22
|
+
import { useProjectStore } from "@/stores/project-store";
|
|
23
|
+
import { cn } from "@/lib/utils";
|
|
24
|
+
|
|
25
|
+
const TAB_ICONS: Record<TabType, React.ElementType> = {
|
|
26
|
+
projects: FolderOpen,
|
|
27
|
+
terminal: Terminal,
|
|
28
|
+
chat: MessageSquare,
|
|
29
|
+
editor: FileCode,
|
|
30
|
+
"git-graph": GitBranch,
|
|
31
|
+
"git-status": GitCommitHorizontal,
|
|
32
|
+
"git-diff": FileDiff,
|
|
33
|
+
settings: Settings,
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
const NEW_TAB_OPTIONS: { type: TabType; label: string }[] = [
|
|
37
|
+
{ type: "projects", label: "Projects" },
|
|
38
|
+
{ type: "terminal", label: "Terminal" },
|
|
39
|
+
{ type: "chat", label: "AI Chat" },
|
|
40
|
+
{ type: "git-graph", label: "Git Graph" },
|
|
41
|
+
{ type: "git-status", label: "Git Status" },
|
|
42
|
+
{ type: "settings", label: "Settings" },
|
|
43
|
+
];
|
|
44
|
+
|
|
45
|
+
export function TabBar() {
|
|
46
|
+
const { tabs, activeTabId, setActiveTab, closeTab, openTab } = useTabStore();
|
|
47
|
+
const activeProject = useProjectStore((s) => s.activeProject);
|
|
48
|
+
const tabRefs = useRef<Map<string, HTMLButtonElement>>(new Map());
|
|
49
|
+
const prevTabCount = useRef(tabs.length);
|
|
50
|
+
|
|
51
|
+
// Auto-scroll to active tab when a new tab is added
|
|
52
|
+
useEffect(() => {
|
|
53
|
+
if (tabs.length > prevTabCount.current && activeTabId) {
|
|
54
|
+
const el = tabRefs.current.get(activeTabId);
|
|
55
|
+
if (el) {
|
|
56
|
+
el.scrollIntoView({ behavior: "smooth", block: "nearest", inline: "nearest" });
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
prevTabCount.current = tabs.length;
|
|
60
|
+
}, [tabs.length, activeTabId]);
|
|
61
|
+
|
|
62
|
+
function handleNewTab(type: TabType) {
|
|
63
|
+
const needsProject =
|
|
64
|
+
type === "git-graph" || type === "git-status" || type === "git-diff" || type === "terminal" || type === "chat";
|
|
65
|
+
const metadata = needsProject
|
|
66
|
+
? { projectName: activeProject?.name }
|
|
67
|
+
: undefined;
|
|
68
|
+
|
|
69
|
+
openTab({
|
|
70
|
+
type,
|
|
71
|
+
title: NEW_TAB_OPTIONS.find((o) => o.type === type)?.label ?? type,
|
|
72
|
+
metadata,
|
|
73
|
+
projectId: activeProject?.name ?? null,
|
|
74
|
+
closable: true,
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
return (
|
|
79
|
+
<div className="hidden md:flex items-center border-b border-border bg-background">
|
|
80
|
+
<ScrollArea className="flex-1">
|
|
81
|
+
<div className="flex items-center gap-0.5 px-2 py-1">
|
|
82
|
+
{tabs.map((tab) => {
|
|
83
|
+
const Icon = TAB_ICONS[tab.type];
|
|
84
|
+
const isActive = tab.id === activeTabId;
|
|
85
|
+
return (
|
|
86
|
+
<button
|
|
87
|
+
key={tab.id}
|
|
88
|
+
ref={(el) => {
|
|
89
|
+
if (el) tabRefs.current.set(tab.id, el);
|
|
90
|
+
else tabRefs.current.delete(tab.id);
|
|
91
|
+
}}
|
|
92
|
+
onClick={() => setActiveTab(tab.id)}
|
|
93
|
+
className={cn(
|
|
94
|
+
"group flex items-center gap-1.5 px-3 py-1.5 text-sm rounded-md whitespace-nowrap transition-colors",
|
|
95
|
+
"border-b-2 -mb-[1px]",
|
|
96
|
+
isActive
|
|
97
|
+
? "border-primary bg-surface text-foreground"
|
|
98
|
+
: "border-transparent text-text-secondary hover:text-foreground hover:bg-surface-elevated",
|
|
99
|
+
)}
|
|
100
|
+
>
|
|
101
|
+
<Icon className="size-4" />
|
|
102
|
+
<span className="max-w-[120px] truncate">{tab.title}</span>
|
|
103
|
+
{tab.closable && (
|
|
104
|
+
<span
|
|
105
|
+
role="button"
|
|
106
|
+
tabIndex={0}
|
|
107
|
+
onClick={(e) => {
|
|
108
|
+
e.stopPropagation();
|
|
109
|
+
closeTab(tab.id);
|
|
110
|
+
}}
|
|
111
|
+
onKeyDown={(e) => {
|
|
112
|
+
if (e.key === "Enter") {
|
|
113
|
+
e.stopPropagation();
|
|
114
|
+
closeTab(tab.id);
|
|
115
|
+
}
|
|
116
|
+
}}
|
|
117
|
+
className="ml-1 opacity-0 group-hover:opacity-100 rounded-sm hover:bg-surface-elevated p-0.5 transition-opacity"
|
|
118
|
+
>
|
|
119
|
+
<X className="size-3" />
|
|
120
|
+
</span>
|
|
121
|
+
)}
|
|
122
|
+
</button>
|
|
123
|
+
);
|
|
124
|
+
})}
|
|
125
|
+
</div>
|
|
126
|
+
<ScrollBar orientation="horizontal" />
|
|
127
|
+
</ScrollArea>
|
|
128
|
+
|
|
129
|
+
<DropdownMenu>
|
|
130
|
+
<DropdownMenuTrigger asChild>
|
|
131
|
+
<button className="flex items-center justify-center size-8 mx-1 rounded-md text-text-secondary hover:text-foreground hover:bg-surface-elevated transition-colors">
|
|
132
|
+
<Plus className="size-4" />
|
|
133
|
+
</button>
|
|
134
|
+
</DropdownMenuTrigger>
|
|
135
|
+
<DropdownMenuContent align="end">
|
|
136
|
+
{NEW_TAB_OPTIONS.map((opt) => {
|
|
137
|
+
const Icon = TAB_ICONS[opt.type];
|
|
138
|
+
return (
|
|
139
|
+
<DropdownMenuItem
|
|
140
|
+
key={opt.type}
|
|
141
|
+
onClick={() => handleNewTab(opt.type)}
|
|
142
|
+
>
|
|
143
|
+
<Icon className="size-4 mr-2" />
|
|
144
|
+
{opt.label}
|
|
145
|
+
</DropdownMenuItem>
|
|
146
|
+
);
|
|
147
|
+
})}
|
|
148
|
+
</DropdownMenuContent>
|
|
149
|
+
</DropdownMenu>
|
|
150
|
+
</div>
|
|
151
|
+
);
|
|
152
|
+
}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { Suspense, lazy } from "react";
|
|
2
|
+
import { useTabStore, type TabType } from "@/stores/tab-store";
|
|
3
|
+
import { Loader2 } from "lucide-react";
|
|
4
|
+
|
|
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
|
+
terminal: lazy(() =>
|
|
12
|
+
import("@/components/terminal/terminal-tab").then((m) => ({
|
|
13
|
+
default: m.TerminalTab,
|
|
14
|
+
})),
|
|
15
|
+
),
|
|
16
|
+
chat: lazy(() =>
|
|
17
|
+
import("@/components/chat/chat-tab").then((m) => ({
|
|
18
|
+
default: m.ChatTab,
|
|
19
|
+
})),
|
|
20
|
+
),
|
|
21
|
+
editor: lazy(() =>
|
|
22
|
+
import("@/components/editor/code-editor").then((m) => ({
|
|
23
|
+
default: m.CodeEditor,
|
|
24
|
+
})),
|
|
25
|
+
),
|
|
26
|
+
"git-graph": lazy(() =>
|
|
27
|
+
import("@/components/git/git-graph").then((m) => ({
|
|
28
|
+
default: m.GitGraph,
|
|
29
|
+
})),
|
|
30
|
+
),
|
|
31
|
+
"git-status": lazy(() =>
|
|
32
|
+
import("@/components/git/git-status-panel").then((m) => ({
|
|
33
|
+
default: m.GitStatusPanel,
|
|
34
|
+
})),
|
|
35
|
+
),
|
|
36
|
+
"git-diff": lazy(() =>
|
|
37
|
+
import("@/components/editor/diff-viewer").then((m) => ({
|
|
38
|
+
default: m.DiffViewer,
|
|
39
|
+
})),
|
|
40
|
+
),
|
|
41
|
+
settings: lazy(() =>
|
|
42
|
+
import("@/components/settings/settings-tab").then((m) => ({
|
|
43
|
+
default: m.SettingsTab,
|
|
44
|
+
})),
|
|
45
|
+
),
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
function LoadingFallback() {
|
|
49
|
+
return (
|
|
50
|
+
<div className="flex items-center justify-center h-full">
|
|
51
|
+
<Loader2 className="size-6 animate-spin text-primary" />
|
|
52
|
+
</div>
|
|
53
|
+
);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function TabContent() {
|
|
57
|
+
const { tabs, activeTabId } = useTabStore();
|
|
58
|
+
|
|
59
|
+
if (tabs.length === 0) {
|
|
60
|
+
return (
|
|
61
|
+
<div className="flex items-center justify-center h-full text-text-secondary">
|
|
62
|
+
<p>No tab open. Use the + button or bottom nav to open one.</p>
|
|
63
|
+
</div>
|
|
64
|
+
);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
return (
|
|
68
|
+
<>
|
|
69
|
+
{tabs.map((tab) => {
|
|
70
|
+
const Component = TAB_COMPONENTS[tab.type];
|
|
71
|
+
const isActive = tab.id === activeTabId;
|
|
72
|
+
return (
|
|
73
|
+
<div
|
|
74
|
+
key={tab.id}
|
|
75
|
+
className={isActive ? "h-full w-full" : "hidden"}
|
|
76
|
+
>
|
|
77
|
+
<Suspense fallback={<LoadingFallback />}>
|
|
78
|
+
<Component metadata={tab.metadata} tabId={tab.id} />
|
|
79
|
+
</Suspense>
|
|
80
|
+
</div>
|
|
81
|
+
);
|
|
82
|
+
})}
|
|
83
|
+
</>
|
|
84
|
+
);
|
|
85
|
+
}
|