@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.
Files changed (159) hide show
  1. package/.claude/agent-memory/tester/MEMORY.md +3 -0
  2. package/.claude/agent-memory/tester/project-ppm-test-conventions.md +32 -0
  3. package/.env.example +1 -0
  4. package/.github/workflows/release.yml +46 -0
  5. package/README.md +349 -0
  6. package/bun.lock +1217 -0
  7. package/components.json +21 -0
  8. package/docs/code-standards.md +574 -0
  9. package/docs/codebase-summary.md +294 -0
  10. package/docs/deployment-guide.md +631 -0
  11. package/docs/design-guidelines.md +661 -0
  12. package/docs/project-overview-pdr.md +142 -0
  13. package/docs/project-roadmap.md +400 -0
  14. package/docs/system-architecture.md +459 -0
  15. package/package.json +68 -0
  16. package/plans/260314-2009-ppm-implementation/phase-01-project-skeleton.md +81 -0
  17. package/plans/260314-2009-ppm-implementation/phase-02-backend-core.md +148 -0
  18. package/plans/260314-2009-ppm-implementation/phase-03-frontend-shell.md +256 -0
  19. package/plans/260314-2009-ppm-implementation/phase-04-file-explorer-editor.md +120 -0
  20. package/plans/260314-2009-ppm-implementation/phase-05-web-terminal.md +174 -0
  21. package/plans/260314-2009-ppm-implementation/phase-06-git-integration.md +244 -0
  22. package/plans/260314-2009-ppm-implementation/phase-07-ai-chat.md +242 -0
  23. package/plans/260314-2009-ppm-implementation/phase-08-cli-commands.md +143 -0
  24. package/plans/260314-2009-ppm-implementation/phase-09-pwa-build-deploy.md +209 -0
  25. package/plans/260314-2009-ppm-implementation/phase-10-testing.md +311 -0
  26. package/plans/260314-2009-ppm-implementation/plan.md +202 -0
  27. package/plans/260315-0356-project-scoped-api-refactor/phase-01-backend-project-router.md +145 -0
  28. package/plans/260315-0356-project-scoped-api-refactor/phase-02-frontend-api-migration.md +107 -0
  29. package/plans/260315-0356-project-scoped-api-refactor/phase-03-per-project-tabs.md +100 -0
  30. package/plans/260315-0356-project-scoped-api-refactor/phase-04-websocket-migration.md +66 -0
  31. package/plans/260315-0356-project-scoped-api-refactor/plan.md +87 -0
  32. package/plans/reports/brainstorm-260314-1938-final-techstack.md +342 -0
  33. package/plans/reports/docs-manager-260315-1314-documentation-creation.md +386 -0
  34. package/plans/reports/fullstack-developer-260314-2252-phase-02-backend-core.md +57 -0
  35. package/plans/reports/fullstack-developer-260314-2253-phase-03-frontend-shell.md +70 -0
  36. package/plans/reports/fullstack-developer-260314-2300-phase-04-05-file-api-terminal-ws.md +49 -0
  37. package/plans/reports/fullstack-developer-260314-2300-phase-04-05-file-explorer-editor-terminal.md +52 -0
  38. package/plans/reports/fullstack-developer-260314-2307-ai-chat-phase7.md +58 -0
  39. package/plans/reports/fullstack-developer-260314-2307-phase-06-git-integration.md +33 -0
  40. package/plans/reports/research-260314-1911-ppm-tech-stack.md +318 -0
  41. package/plans/reports/research-260314-1930-claude-code-integration.md +293 -0
  42. package/plans/reports/researcher-260314-2232-node-pty-bun-crash-analysis.md +305 -0
  43. package/plans/reports/researcher-260314-2232-ui-style.md +942 -0
  44. package/plans/reports/researcher-260315-0300-opcode-claude-interaction.md +745 -0
  45. package/plans/reports/researcher-260315-0303-opcode-deep-analysis.md +742 -0
  46. package/plans/reports/researcher-260315-0305-claude-agent-sdk-github-research.md +423 -0
  47. package/plans/reports/tester-260314-2053-initial-test-suite.md +81 -0
  48. package/ppm.example.yaml +14 -0
  49. package/repomix-output.xml +23745 -0
  50. package/scripts/build.ts +13 -0
  51. package/src/cli/commands/chat-cmd.ts +259 -0
  52. package/src/cli/commands/config-cmd.ts +121 -0
  53. package/src/cli/commands/git-cmd.ts +315 -0
  54. package/src/cli/commands/init.ts +57 -0
  55. package/src/cli/commands/open.ts +19 -0
  56. package/src/cli/commands/projects.ts +100 -0
  57. package/src/cli/commands/start.ts +3 -0
  58. package/src/cli/commands/stop.ts +33 -0
  59. package/src/cli/utils/project-resolver.ts +27 -0
  60. package/src/index.ts +59 -0
  61. package/src/providers/claude-agent-sdk.ts +499 -0
  62. package/src/providers/claude-binary-finder.ts +256 -0
  63. package/src/providers/claude-code-cli.ts +413 -0
  64. package/src/providers/claude-process-registry.ts +106 -0
  65. package/src/providers/mock-provider.ts +171 -0
  66. package/src/providers/provider.interface.ts +10 -0
  67. package/src/providers/registry.ts +45 -0
  68. package/src/server/helpers/resolve-project.ts +22 -0
  69. package/src/server/index.ts +181 -0
  70. package/src/server/middleware/auth.ts +30 -0
  71. package/src/server/routes/chat.ts +153 -0
  72. package/src/server/routes/files.ts +168 -0
  73. package/src/server/routes/git.ts +261 -0
  74. package/src/server/routes/project-scoped.ts +27 -0
  75. package/src/server/routes/projects.ts +57 -0
  76. package/src/server/routes/static.ts +26 -0
  77. package/src/server/ws/chat.ts +130 -0
  78. package/src/server/ws/terminal.ts +89 -0
  79. package/src/services/chat.service.ts +110 -0
  80. package/src/services/claude-usage.service.ts +113 -0
  81. package/src/services/config.service.ts +90 -0
  82. package/src/services/file.service.ts +261 -0
  83. package/src/services/git-dirs.service.ts +112 -0
  84. package/src/services/git.service.ts +372 -0
  85. package/src/services/project.service.ts +107 -0
  86. package/src/services/slash-items.service.ts +184 -0
  87. package/src/services/terminal.service.ts +212 -0
  88. package/src/types/api.ts +37 -0
  89. package/src/types/chat.ts +92 -0
  90. package/src/types/config.ts +41 -0
  91. package/src/types/git.ts +50 -0
  92. package/src/types/project.ts +18 -0
  93. package/src/types/terminal.ts +20 -0
  94. package/src/web/app.tsx +168 -0
  95. package/src/web/components/auth/login-screen.tsx +88 -0
  96. package/src/web/components/chat/attachment-chips.tsx +55 -0
  97. package/src/web/components/chat/chat-placeholder.tsx +10 -0
  98. package/src/web/components/chat/chat-tab.tsx +301 -0
  99. package/src/web/components/chat/file-picker.tsx +126 -0
  100. package/src/web/components/chat/message-input.tsx +420 -0
  101. package/src/web/components/chat/message-list.tsx +838 -0
  102. package/src/web/components/chat/session-picker.tsx +139 -0
  103. package/src/web/components/chat/slash-command-picker.tsx +135 -0
  104. package/src/web/components/chat/usage-badge.tsx +186 -0
  105. package/src/web/components/editor/code-editor.tsx +329 -0
  106. package/src/web/components/editor/diff-viewer.tsx +276 -0
  107. package/src/web/components/editor/editor-placeholder.tsx +10 -0
  108. package/src/web/components/explorer/file-actions.tsx +191 -0
  109. package/src/web/components/explorer/file-tree.tsx +298 -0
  110. package/src/web/components/git/git-graph.tsx +727 -0
  111. package/src/web/components/git/git-placeholder.tsx +55 -0
  112. package/src/web/components/git/git-status-panel.tsx +850 -0
  113. package/src/web/components/layout/mobile-drawer.tsx +137 -0
  114. package/src/web/components/layout/mobile-nav.tsx +103 -0
  115. package/src/web/components/layout/sidebar.tsx +90 -0
  116. package/src/web/components/layout/tab-bar.tsx +152 -0
  117. package/src/web/components/layout/tab-content.tsx +85 -0
  118. package/src/web/components/projects/dir-suggest.tsx +152 -0
  119. package/src/web/components/projects/project-list.tsx +187 -0
  120. package/src/web/components/settings/settings-tab.tsx +57 -0
  121. package/src/web/components/terminal/terminal-placeholder.tsx +10 -0
  122. package/src/web/components/terminal/terminal-tab.tsx +133 -0
  123. package/src/web/components/ui/button.tsx +64 -0
  124. package/src/web/components/ui/context-menu.tsx +250 -0
  125. package/src/web/components/ui/dialog.tsx +156 -0
  126. package/src/web/components/ui/dropdown-menu.tsx +257 -0
  127. package/src/web/components/ui/input.tsx +21 -0
  128. package/src/web/components/ui/scroll-area.tsx +56 -0
  129. package/src/web/components/ui/separator.tsx +26 -0
  130. package/src/web/components/ui/sonner.tsx +40 -0
  131. package/src/web/components/ui/tabs.tsx +91 -0
  132. package/src/web/components/ui/tooltip.tsx +57 -0
  133. package/src/web/hooks/use-chat.ts +420 -0
  134. package/src/web/hooks/use-terminal.ts +182 -0
  135. package/src/web/hooks/use-url-sync.ts +66 -0
  136. package/src/web/hooks/use-websocket.ts +48 -0
  137. package/src/web/index.html +16 -0
  138. package/src/web/lib/api-client.ts +90 -0
  139. package/src/web/lib/file-support.ts +68 -0
  140. package/src/web/lib/utils.ts +6 -0
  141. package/src/web/lib/ws-client.ts +100 -0
  142. package/src/web/main.tsx +10 -0
  143. package/src/web/public/icon-192.svg +5 -0
  144. package/src/web/public/icon-512.svg +5 -0
  145. package/src/web/stores/file-store.ts +81 -0
  146. package/src/web/stores/project-store.ts +50 -0
  147. package/src/web/stores/settings-store.ts +65 -0
  148. package/src/web/stores/tab-store.ts +187 -0
  149. package/src/web/styles/globals.css +227 -0
  150. package/src/web/vite-env.d.ts +1 -0
  151. package/tests/integration/api/chat-routes.test.ts +95 -0
  152. package/tests/integration/claude-agent-sdk-integration.test.ts +228 -0
  153. package/tests/integration/ws/chat-websocket.test.ts +312 -0
  154. package/tests/test-setup.ts +5 -0
  155. package/tests/unit/providers/claude-agent-sdk.test.ts +339 -0
  156. package/tests/unit/providers/mock-provider.test.ts +143 -0
  157. package/tests/unit/services/chat-service.test.ts +100 -0
  158. package/tsconfig.json +32 -0
  159. 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
+ }