@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,152 @@
|
|
|
1
|
+
import { useState, useEffect, useRef, useCallback } from "react";
|
|
2
|
+
import { FolderGit2, Loader2 } from "lucide-react";
|
|
3
|
+
import { api } from "@/lib/api-client";
|
|
4
|
+
import { Input } from "@/components/ui/input";
|
|
5
|
+
|
|
6
|
+
interface DirSuggestItem {
|
|
7
|
+
path: string;
|
|
8
|
+
name: string;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
interface DirSuggestProps {
|
|
12
|
+
value: string;
|
|
13
|
+
onChange: (value: string) => void;
|
|
14
|
+
onSelect?: (item: DirSuggestItem) => void;
|
|
15
|
+
placeholder?: string;
|
|
16
|
+
autoFocus?: boolean;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function DirSuggest({ value, onChange, onSelect, placeholder, autoFocus }: DirSuggestProps) {
|
|
20
|
+
const [allDirs, setAllDirs] = useState<DirSuggestItem[]>([]);
|
|
21
|
+
const [filtered, setFiltered] = useState<DirSuggestItem[]>([]);
|
|
22
|
+
const [showSuggestions, setShowSuggestions] = useState(false);
|
|
23
|
+
const [selectedIndex, setSelectedIndex] = useState(0);
|
|
24
|
+
const [loading, setLoading] = useState(false);
|
|
25
|
+
const listRef = useRef<HTMLDivElement>(null);
|
|
26
|
+
const fetchedRef = useRef(false);
|
|
27
|
+
|
|
28
|
+
// Fetch all git dirs once on mount (cached server-side for 5 min)
|
|
29
|
+
useEffect(() => {
|
|
30
|
+
if (fetchedRef.current) return;
|
|
31
|
+
fetchedRef.current = true;
|
|
32
|
+
setLoading(true);
|
|
33
|
+
api
|
|
34
|
+
.get<DirSuggestItem[]>("/api/projects/suggest-dirs")
|
|
35
|
+
.then((items) => {
|
|
36
|
+
setAllDirs(items);
|
|
37
|
+
setFiltered(items.slice(0, 50));
|
|
38
|
+
setShowSuggestions(items.length > 0);
|
|
39
|
+
})
|
|
40
|
+
.catch(() => setAllDirs([]))
|
|
41
|
+
.finally(() => setLoading(false));
|
|
42
|
+
}, []);
|
|
43
|
+
|
|
44
|
+
// Filter locally when value changes
|
|
45
|
+
useEffect(() => {
|
|
46
|
+
if (allDirs.length === 0) return;
|
|
47
|
+
const q = value.trim().toLowerCase();
|
|
48
|
+
if (!q) {
|
|
49
|
+
setFiltered(allDirs.slice(0, 50));
|
|
50
|
+
} else {
|
|
51
|
+
setFiltered(
|
|
52
|
+
allDirs
|
|
53
|
+
.filter((d) => d.path.toLowerCase().includes(q) || d.name.toLowerCase().includes(q))
|
|
54
|
+
.slice(0, 50),
|
|
55
|
+
);
|
|
56
|
+
}
|
|
57
|
+
setSelectedIndex(0);
|
|
58
|
+
}, [value, allDirs]);
|
|
59
|
+
|
|
60
|
+
// Scroll selected into view
|
|
61
|
+
useEffect(() => {
|
|
62
|
+
const list = listRef.current;
|
|
63
|
+
if (!list) return;
|
|
64
|
+
const selected = list.children[selectedIndex] as HTMLElement | undefined;
|
|
65
|
+
selected?.scrollIntoView({ block: "nearest" });
|
|
66
|
+
}, [selectedIndex]);
|
|
67
|
+
|
|
68
|
+
const handleSelect = useCallback(
|
|
69
|
+
(item: DirSuggestItem) => {
|
|
70
|
+
onChange(item.path);
|
|
71
|
+
onSelect?.(item);
|
|
72
|
+
setShowSuggestions(false);
|
|
73
|
+
},
|
|
74
|
+
[onChange, onSelect],
|
|
75
|
+
);
|
|
76
|
+
|
|
77
|
+
const handleKeyDown = useCallback(
|
|
78
|
+
(e: React.KeyboardEvent) => {
|
|
79
|
+
if (!showSuggestions || filtered.length === 0) return;
|
|
80
|
+
|
|
81
|
+
switch (e.key) {
|
|
82
|
+
case "ArrowDown":
|
|
83
|
+
e.preventDefault();
|
|
84
|
+
setSelectedIndex((i) => (i < filtered.length - 1 ? i + 1 : 0));
|
|
85
|
+
break;
|
|
86
|
+
case "ArrowUp":
|
|
87
|
+
e.preventDefault();
|
|
88
|
+
setSelectedIndex((i) => (i > 0 ? i - 1 : filtered.length - 1));
|
|
89
|
+
break;
|
|
90
|
+
case "Tab":
|
|
91
|
+
case "Enter":
|
|
92
|
+
if (filtered[selectedIndex]) {
|
|
93
|
+
e.preventDefault();
|
|
94
|
+
handleSelect(filtered[selectedIndex]);
|
|
95
|
+
}
|
|
96
|
+
break;
|
|
97
|
+
case "Escape":
|
|
98
|
+
e.preventDefault();
|
|
99
|
+
setShowSuggestions(false);
|
|
100
|
+
break;
|
|
101
|
+
}
|
|
102
|
+
},
|
|
103
|
+
[showSuggestions, filtered, selectedIndex, handleSelect],
|
|
104
|
+
);
|
|
105
|
+
|
|
106
|
+
return (
|
|
107
|
+
<div className="relative">
|
|
108
|
+
<div className="relative">
|
|
109
|
+
<Input
|
|
110
|
+
placeholder={placeholder ?? "/home/user/my-project"}
|
|
111
|
+
value={value}
|
|
112
|
+
onChange={(e) => onChange(e.target.value)}
|
|
113
|
+
onKeyDown={handleKeyDown}
|
|
114
|
+
onFocus={() => filtered.length > 0 && setShowSuggestions(true)}
|
|
115
|
+
onBlur={() => setTimeout(() => setShowSuggestions(false), 200)}
|
|
116
|
+
autoFocus={autoFocus}
|
|
117
|
+
/>
|
|
118
|
+
{loading && (
|
|
119
|
+
<Loader2 className="absolute right-2 top-1/2 -translate-y-1/2 size-4 text-text-subtle animate-spin" />
|
|
120
|
+
)}
|
|
121
|
+
</div>
|
|
122
|
+
{showSuggestions && filtered.length > 0 && (
|
|
123
|
+
<div className="absolute z-50 left-0 right-0 top-full mt-1 max-h-48 overflow-y-auto rounded-md border border-border bg-surface shadow-lg">
|
|
124
|
+
<div ref={listRef} className="py-1">
|
|
125
|
+
{filtered.map((item, i) => (
|
|
126
|
+
<button
|
|
127
|
+
key={item.path}
|
|
128
|
+
type="button"
|
|
129
|
+
className={`flex items-center gap-2 w-full px-3 py-1.5 text-left text-sm transition-colors ${
|
|
130
|
+
i === selectedIndex
|
|
131
|
+
? "bg-primary/10 text-primary"
|
|
132
|
+
: "hover:bg-surface-hover text-text-primary"
|
|
133
|
+
}`}
|
|
134
|
+
onMouseEnter={() => setSelectedIndex(i)}
|
|
135
|
+
onMouseDown={(e) => {
|
|
136
|
+
e.preventDefault();
|
|
137
|
+
handleSelect(item);
|
|
138
|
+
}}
|
|
139
|
+
>
|
|
140
|
+
<FolderGit2 className="size-4 text-green-500 shrink-0" />
|
|
141
|
+
<div className="min-w-0 flex-1 flex items-baseline gap-2">
|
|
142
|
+
<span className="font-medium">{item.name}</span>
|
|
143
|
+
<span className="text-xs text-text-subtle truncate">{item.path}</span>
|
|
144
|
+
</div>
|
|
145
|
+
</button>
|
|
146
|
+
))}
|
|
147
|
+
</div>
|
|
148
|
+
</div>
|
|
149
|
+
)}
|
|
150
|
+
</div>
|
|
151
|
+
);
|
|
152
|
+
}
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
import { useEffect, useState, useCallback } from "react";
|
|
2
|
+
import { FolderOpen, GitBranch, Circle, Plus } from "lucide-react";
|
|
3
|
+
import { useProjectStore } from "@/stores/project-store";
|
|
4
|
+
import { useTabStore } from "@/stores/tab-store";
|
|
5
|
+
import { api } from "@/lib/api-client";
|
|
6
|
+
import { cn } from "@/lib/utils";
|
|
7
|
+
import {
|
|
8
|
+
Dialog,
|
|
9
|
+
DialogContent,
|
|
10
|
+
DialogHeader,
|
|
11
|
+
DialogTitle,
|
|
12
|
+
DialogFooter,
|
|
13
|
+
} from "@/components/ui/dialog";
|
|
14
|
+
import { Input } from "@/components/ui/input";
|
|
15
|
+
import { Button } from "@/components/ui/button";
|
|
16
|
+
import { DirSuggest } from "./dir-suggest";
|
|
17
|
+
|
|
18
|
+
export function ProjectList() {
|
|
19
|
+
const { projects, activeProject, setActiveProject, fetchProjects, loading, error } =
|
|
20
|
+
useProjectStore();
|
|
21
|
+
const openTab = useTabStore((s) => s.openTab);
|
|
22
|
+
const [showAdd, setShowAdd] = useState(false);
|
|
23
|
+
const [addPath, setAddPath] = useState("");
|
|
24
|
+
const [addName, setAddName] = useState("");
|
|
25
|
+
const [addError, setAddError] = useState("");
|
|
26
|
+
|
|
27
|
+
useEffect(() => {
|
|
28
|
+
fetchProjects();
|
|
29
|
+
}, [fetchProjects]);
|
|
30
|
+
|
|
31
|
+
function handleClick(project: (typeof projects)[number]) {
|
|
32
|
+
setActiveProject(project);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function handleOpen(project: (typeof projects)[number]) {
|
|
36
|
+
setActiveProject(project);
|
|
37
|
+
openTab({
|
|
38
|
+
type: "terminal",
|
|
39
|
+
title: `Terminal - ${project.name}`,
|
|
40
|
+
metadata: { projectName: project.name },
|
|
41
|
+
projectId: project.name,
|
|
42
|
+
closable: true,
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const handleAddProject = useCallback(async () => {
|
|
47
|
+
if (!addPath.trim()) return;
|
|
48
|
+
setAddError("");
|
|
49
|
+
try {
|
|
50
|
+
await api.post("/api/projects", {
|
|
51
|
+
path: addPath.trim(),
|
|
52
|
+
name: addName.trim() || undefined,
|
|
53
|
+
});
|
|
54
|
+
await fetchProjects();
|
|
55
|
+
setShowAdd(false);
|
|
56
|
+
setAddPath("");
|
|
57
|
+
setAddName("");
|
|
58
|
+
} catch (e) {
|
|
59
|
+
setAddError(e instanceof Error ? e.message : "Failed to add project");
|
|
60
|
+
}
|
|
61
|
+
}, [addPath, addName, fetchProjects]);
|
|
62
|
+
|
|
63
|
+
if (error) {
|
|
64
|
+
return (
|
|
65
|
+
<div className="flex items-center justify-center h-full p-4">
|
|
66
|
+
<p className="text-error text-sm">{error}</p>
|
|
67
|
+
</div>
|
|
68
|
+
);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
return (
|
|
72
|
+
<div className="h-full p-4 space-y-4 overflow-auto">
|
|
73
|
+
<div className="flex items-center justify-between">
|
|
74
|
+
<h2 className="text-lg font-semibold">Projects</h2>
|
|
75
|
+
<Button
|
|
76
|
+
variant="outline"
|
|
77
|
+
size="sm"
|
|
78
|
+
onClick={() => setShowAdd(true)}
|
|
79
|
+
className="gap-1.5"
|
|
80
|
+
>
|
|
81
|
+
<Plus className="size-4" />
|
|
82
|
+
Add Project
|
|
83
|
+
</Button>
|
|
84
|
+
</div>
|
|
85
|
+
|
|
86
|
+
{loading && (
|
|
87
|
+
<p className="text-text-secondary text-sm">Loading projects...</p>
|
|
88
|
+
)}
|
|
89
|
+
|
|
90
|
+
{!loading && projects.length === 0 && (
|
|
91
|
+
<div className="text-center py-8 space-y-2">
|
|
92
|
+
<FolderOpen className="size-10 mx-auto text-text-subtle" />
|
|
93
|
+
<p className="text-text-secondary text-sm">No projects registered</p>
|
|
94
|
+
<p className="text-text-subtle text-xs">
|
|
95
|
+
Click "Add Project" or use <code className="font-mono bg-surface-elevated px-1 py-0.5 rounded">ppm projects add <path></code>
|
|
96
|
+
</p>
|
|
97
|
+
</div>
|
|
98
|
+
)}
|
|
99
|
+
|
|
100
|
+
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
|
101
|
+
{projects.map((project) => (
|
|
102
|
+
<button
|
|
103
|
+
key={project.name}
|
|
104
|
+
onClick={() => handleClick(project)}
|
|
105
|
+
onDoubleClick={() => handleOpen(project)}
|
|
106
|
+
className={cn(
|
|
107
|
+
"text-left p-4 rounded-lg border transition-colors",
|
|
108
|
+
"min-h-[44px]",
|
|
109
|
+
activeProject?.name === project.name
|
|
110
|
+
? "bg-surface border-primary"
|
|
111
|
+
: "bg-surface border-border hover:border-text-subtle",
|
|
112
|
+
)}
|
|
113
|
+
>
|
|
114
|
+
<div className="flex items-start gap-3">
|
|
115
|
+
<FolderOpen className="size-5 text-primary shrink-0 mt-0.5" />
|
|
116
|
+
<div className="flex-1 min-w-0 space-y-1">
|
|
117
|
+
<p className="font-medium truncate">{project.name}</p>
|
|
118
|
+
<p className="text-xs text-text-secondary truncate">
|
|
119
|
+
{project.path}
|
|
120
|
+
</p>
|
|
121
|
+
{project.branch && (
|
|
122
|
+
<div className="flex items-center gap-1.5 text-xs text-text-secondary">
|
|
123
|
+
<GitBranch className="size-3" />
|
|
124
|
+
<span>{project.branch}</span>
|
|
125
|
+
{project.status && (
|
|
126
|
+
<Circle
|
|
127
|
+
className={cn(
|
|
128
|
+
"size-2 fill-current",
|
|
129
|
+
project.status === "clean"
|
|
130
|
+
? "text-success"
|
|
131
|
+
: "text-warning",
|
|
132
|
+
)}
|
|
133
|
+
/>
|
|
134
|
+
)}
|
|
135
|
+
</div>
|
|
136
|
+
)}
|
|
137
|
+
</div>
|
|
138
|
+
</div>
|
|
139
|
+
</button>
|
|
140
|
+
))}
|
|
141
|
+
</div>
|
|
142
|
+
|
|
143
|
+
{/* Add Project Dialog */}
|
|
144
|
+
<Dialog open={showAdd} onOpenChange={setShowAdd}>
|
|
145
|
+
<DialogContent>
|
|
146
|
+
<DialogHeader>
|
|
147
|
+
<DialogTitle>Add Project</DialogTitle>
|
|
148
|
+
</DialogHeader>
|
|
149
|
+
<div className="space-y-3">
|
|
150
|
+
<div>
|
|
151
|
+
<label className="text-sm text-text-secondary">Path (required)</label>
|
|
152
|
+
<DirSuggest
|
|
153
|
+
value={addPath}
|
|
154
|
+
onChange={setAddPath}
|
|
155
|
+
onSelect={(item) => {
|
|
156
|
+
if (!addName.trim()) setAddName(item.name);
|
|
157
|
+
}}
|
|
158
|
+
placeholder="/home/user/my-project"
|
|
159
|
+
autoFocus
|
|
160
|
+
/>
|
|
161
|
+
</div>
|
|
162
|
+
<div>
|
|
163
|
+
<label className="text-sm text-text-secondary">Name (optional)</label>
|
|
164
|
+
<Input
|
|
165
|
+
placeholder="Auto-detected from folder name"
|
|
166
|
+
value={addName}
|
|
167
|
+
onChange={(e) => setAddName(e.target.value)}
|
|
168
|
+
onKeyDown={(e) => e.key === "Enter" && handleAddProject()}
|
|
169
|
+
/>
|
|
170
|
+
</div>
|
|
171
|
+
{addError && (
|
|
172
|
+
<p className="text-sm text-error">{addError}</p>
|
|
173
|
+
)}
|
|
174
|
+
</div>
|
|
175
|
+
<DialogFooter>
|
|
176
|
+
<Button variant="outline" onClick={() => setShowAdd(false)}>
|
|
177
|
+
Cancel
|
|
178
|
+
</Button>
|
|
179
|
+
<Button onClick={handleAddProject} disabled={!addPath.trim()}>
|
|
180
|
+
Add
|
|
181
|
+
</Button>
|
|
182
|
+
</DialogFooter>
|
|
183
|
+
</DialogContent>
|
|
184
|
+
</Dialog>
|
|
185
|
+
</div>
|
|
186
|
+
);
|
|
187
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { Moon, Sun, Monitor } from "lucide-react";
|
|
2
|
+
import { Button } from "@/components/ui/button";
|
|
3
|
+
import { Separator } from "@/components/ui/separator";
|
|
4
|
+
import { useSettingsStore, type Theme } from "@/stores/settings-store";
|
|
5
|
+
import { cn } from "@/lib/utils";
|
|
6
|
+
|
|
7
|
+
const THEME_OPTIONS: { value: Theme; label: string; icon: React.ElementType }[] = [
|
|
8
|
+
{ value: "light", label: "Light", icon: Sun },
|
|
9
|
+
{ value: "dark", label: "Dark", icon: Moon },
|
|
10
|
+
{ value: "system", label: "System", icon: Monitor },
|
|
11
|
+
];
|
|
12
|
+
|
|
13
|
+
export function SettingsTab() {
|
|
14
|
+
const { theme, setTheme } = useSettingsStore();
|
|
15
|
+
|
|
16
|
+
return (
|
|
17
|
+
<div className="h-full p-4 space-y-6 overflow-auto max-w-lg">
|
|
18
|
+
<h2 className="text-lg font-semibold">Settings</h2>
|
|
19
|
+
|
|
20
|
+
<div className="space-y-3">
|
|
21
|
+
<h3 className="text-sm font-medium text-text-secondary">Theme</h3>
|
|
22
|
+
<div className="flex gap-2">
|
|
23
|
+
{THEME_OPTIONS.map((opt) => {
|
|
24
|
+
const Icon = opt.icon;
|
|
25
|
+
return (
|
|
26
|
+
<Button
|
|
27
|
+
key={opt.value}
|
|
28
|
+
variant={theme === opt.value ? "default" : "outline"}
|
|
29
|
+
size="lg"
|
|
30
|
+
onClick={() => setTheme(opt.value)}
|
|
31
|
+
className={cn(
|
|
32
|
+
"flex-1 gap-2",
|
|
33
|
+
theme === opt.value && "ring-2 ring-primary",
|
|
34
|
+
)}
|
|
35
|
+
>
|
|
36
|
+
<Icon className="size-4" />
|
|
37
|
+
{opt.label}
|
|
38
|
+
</Button>
|
|
39
|
+
);
|
|
40
|
+
})}
|
|
41
|
+
</div>
|
|
42
|
+
</div>
|
|
43
|
+
|
|
44
|
+
<Separator />
|
|
45
|
+
|
|
46
|
+
<div className="space-y-3">
|
|
47
|
+
<h3 className="text-sm font-medium text-text-secondary">About</h3>
|
|
48
|
+
<p className="text-sm text-text-secondary">
|
|
49
|
+
PPM — Personal Project Manager
|
|
50
|
+
</p>
|
|
51
|
+
<p className="text-xs text-text-subtle">
|
|
52
|
+
A mobile-first web IDE for managing your projects.
|
|
53
|
+
</p>
|
|
54
|
+
</div>
|
|
55
|
+
</div>
|
|
56
|
+
);
|
|
57
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { Terminal } from "lucide-react";
|
|
2
|
+
|
|
3
|
+
export function TerminalPlaceholder() {
|
|
4
|
+
return (
|
|
5
|
+
<div className="flex flex-col items-center justify-center h-full gap-3 text-text-secondary">
|
|
6
|
+
<Terminal className="size-10 text-text-subtle" />
|
|
7
|
+
<p className="text-sm">Terminal — coming in Phase 5</p>
|
|
8
|
+
</div>
|
|
9
|
+
);
|
|
10
|
+
}
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
import { useRef, useEffect, useState, useCallback } from "react";
|
|
2
|
+
import { useTerminal } from "@/hooks/use-terminal";
|
|
3
|
+
import { cn } from "@/lib/utils";
|
|
4
|
+
import "@xterm/xterm/css/xterm.css";
|
|
5
|
+
|
|
6
|
+
interface TerminalTabProps {
|
|
7
|
+
metadata?: Record<string, unknown>;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
const MOBILE_KEYS = [
|
|
11
|
+
{ label: "Tab", value: "\t" },
|
|
12
|
+
{ label: "Esc", value: "\x1b" },
|
|
13
|
+
{ label: "Ctrl", value: null, isModifier: true },
|
|
14
|
+
{ label: "\u2191", value: "\x1b[A" },
|
|
15
|
+
{ label: "\u2193", value: "\x1b[B" },
|
|
16
|
+
{ label: "\u2190", value: "\x1b[D" },
|
|
17
|
+
{ label: "\u2192", value: "\x1b[C" },
|
|
18
|
+
] as const;
|
|
19
|
+
|
|
20
|
+
export function TerminalTab({ metadata }: TerminalTabProps) {
|
|
21
|
+
const sessionId = (metadata?.sessionId as string) ?? "new";
|
|
22
|
+
const projectName = metadata?.projectName as string | undefined;
|
|
23
|
+
const containerRef = useRef<HTMLDivElement>(null);
|
|
24
|
+
const { connected, reconnecting } = useTerminal({ sessionId, projectName, containerRef });
|
|
25
|
+
const [ctrlMode, setCtrlMode] = useState(false);
|
|
26
|
+
const [viewportHeight, setViewportHeight] = useState<number | null>(null);
|
|
27
|
+
|
|
28
|
+
// Adjust height when mobile keyboard opens
|
|
29
|
+
useEffect(() => {
|
|
30
|
+
const vv = window.visualViewport;
|
|
31
|
+
if (!vv) return;
|
|
32
|
+
|
|
33
|
+
function handleResize() {
|
|
34
|
+
if (!vv) return;
|
|
35
|
+
setViewportHeight(vv.height);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
vv.addEventListener("resize", handleResize);
|
|
39
|
+
return () => vv.removeEventListener("resize", handleResize);
|
|
40
|
+
}, []);
|
|
41
|
+
|
|
42
|
+
const sendKey = useCallback(
|
|
43
|
+
(value: string) => {
|
|
44
|
+
// Access the terminal container's xterm instance indirectly via the WS
|
|
45
|
+
// The useTerminal hook handles this — we need to dispatch to the terminal
|
|
46
|
+
const termElement = containerRef.current?.querySelector(
|
|
47
|
+
".xterm-helper-textarea",
|
|
48
|
+
) as HTMLTextAreaElement | null;
|
|
49
|
+
|
|
50
|
+
if (termElement) {
|
|
51
|
+
termElement.focus();
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// For Ctrl combos, we rely on the terminal processing keystrokes
|
|
55
|
+
// For direct chars, dispatch input event
|
|
56
|
+
if (ctrlMode && value.length === 1) {
|
|
57
|
+
// Ctrl+key: send char code 1-26 for a-z
|
|
58
|
+
const code = value.toLowerCase().charCodeAt(0) - 96;
|
|
59
|
+
if (code >= 1 && code <= 26) {
|
|
60
|
+
// The terminal onData handler in useTerminal sends to WS
|
|
61
|
+
const event = new KeyboardEvent("keydown", {
|
|
62
|
+
key: value,
|
|
63
|
+
ctrlKey: true,
|
|
64
|
+
bubbles: true,
|
|
65
|
+
});
|
|
66
|
+
termElement?.dispatchEvent(event);
|
|
67
|
+
}
|
|
68
|
+
setCtrlMode(false);
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// For simple values, use input event approach by focusing textarea
|
|
73
|
+
// xterm handles the rest
|
|
74
|
+
},
|
|
75
|
+
[ctrlMode],
|
|
76
|
+
);
|
|
77
|
+
|
|
78
|
+
const isMobile = typeof window !== "undefined" && "ontouchstart" in window;
|
|
79
|
+
|
|
80
|
+
return (
|
|
81
|
+
<div
|
|
82
|
+
className="flex flex-col h-full"
|
|
83
|
+
style={viewportHeight ? { maxHeight: `${viewportHeight}px` } : undefined}
|
|
84
|
+
>
|
|
85
|
+
{/* Status bar */}
|
|
86
|
+
<div className="flex items-center gap-2 px-3 py-1 bg-surface border-b border-border text-xs">
|
|
87
|
+
<span
|
|
88
|
+
className={cn(
|
|
89
|
+
"size-2 rounded-full",
|
|
90
|
+
connected ? "bg-success" : reconnecting ? "bg-warning" : "bg-error",
|
|
91
|
+
)}
|
|
92
|
+
/>
|
|
93
|
+
<span className="text-text-secondary">
|
|
94
|
+
{connected
|
|
95
|
+
? "Connected"
|
|
96
|
+
: reconnecting
|
|
97
|
+
? "Reconnecting..."
|
|
98
|
+
: "Disconnected"}
|
|
99
|
+
</span>
|
|
100
|
+
<span className="text-text-subtle ml-auto font-mono">{sessionId}</span>
|
|
101
|
+
</div>
|
|
102
|
+
|
|
103
|
+
{/* Terminal container */}
|
|
104
|
+
<div ref={containerRef} className="flex-1 min-h-0 bg-background p-1" />
|
|
105
|
+
|
|
106
|
+
{/* Mobile toolbar */}
|
|
107
|
+
{isMobile && (
|
|
108
|
+
<div className="flex items-center gap-1 px-2 py-1.5 bg-surface border-t border-border overflow-x-auto">
|
|
109
|
+
{MOBILE_KEYS.map((key) => (
|
|
110
|
+
<button
|
|
111
|
+
key={key.label}
|
|
112
|
+
onClick={() => {
|
|
113
|
+
if (key.label === "Ctrl") {
|
|
114
|
+
setCtrlMode(!ctrlMode);
|
|
115
|
+
} else if (key.value) {
|
|
116
|
+
sendKey(key.value);
|
|
117
|
+
}
|
|
118
|
+
}}
|
|
119
|
+
className={cn(
|
|
120
|
+
"px-3 py-1.5 rounded text-xs font-mono min-w-[36px] min-h-[32px]",
|
|
121
|
+
"bg-surface-elevated text-text-primary active:bg-primary active:text-primary-foreground",
|
|
122
|
+
"transition-colors select-none",
|
|
123
|
+
key.label === "Ctrl" && ctrlMode && "bg-primary text-primary-foreground",
|
|
124
|
+
)}
|
|
125
|
+
>
|
|
126
|
+
{key.label}
|
|
127
|
+
</button>
|
|
128
|
+
))}
|
|
129
|
+
</div>
|
|
130
|
+
)}
|
|
131
|
+
</div>
|
|
132
|
+
);
|
|
133
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import * as React from "react"
|
|
2
|
+
import { cva, type VariantProps } from "class-variance-authority"
|
|
3
|
+
import { Slot } from "radix-ui"
|
|
4
|
+
|
|
5
|
+
import { cn } from "@/lib/utils"
|
|
6
|
+
|
|
7
|
+
const buttonVariants = cva(
|
|
8
|
+
"inline-flex shrink-0 items-center justify-center gap-2 rounded-md text-sm font-medium whitespace-nowrap transition-all outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
|
9
|
+
{
|
|
10
|
+
variants: {
|
|
11
|
+
variant: {
|
|
12
|
+
default: "bg-primary text-primary-foreground hover:bg-primary/90",
|
|
13
|
+
destructive:
|
|
14
|
+
"bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:bg-destructive/60 dark:focus-visible:ring-destructive/40",
|
|
15
|
+
outline:
|
|
16
|
+
"border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50",
|
|
17
|
+
secondary:
|
|
18
|
+
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
|
19
|
+
ghost:
|
|
20
|
+
"hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
|
|
21
|
+
link: "text-primary underline-offset-4 hover:underline",
|
|
22
|
+
},
|
|
23
|
+
size: {
|
|
24
|
+
default: "h-9 px-4 py-2 has-[>svg]:px-3",
|
|
25
|
+
xs: "h-6 gap-1 rounded-md px-2 text-xs has-[>svg]:px-1.5 [&_svg:not([class*='size-'])]:size-3",
|
|
26
|
+
sm: "h-8 gap-1.5 rounded-md px-3 has-[>svg]:px-2.5",
|
|
27
|
+
lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
|
|
28
|
+
icon: "size-9",
|
|
29
|
+
"icon-xs": "size-6 rounded-md [&_svg:not([class*='size-'])]:size-3",
|
|
30
|
+
"icon-sm": "size-8",
|
|
31
|
+
"icon-lg": "size-10",
|
|
32
|
+
},
|
|
33
|
+
},
|
|
34
|
+
defaultVariants: {
|
|
35
|
+
variant: "default",
|
|
36
|
+
size: "default",
|
|
37
|
+
},
|
|
38
|
+
}
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
function Button({
|
|
42
|
+
className,
|
|
43
|
+
variant = "default",
|
|
44
|
+
size = "default",
|
|
45
|
+
asChild = false,
|
|
46
|
+
...props
|
|
47
|
+
}: React.ComponentProps<"button"> &
|
|
48
|
+
VariantProps<typeof buttonVariants> & {
|
|
49
|
+
asChild?: boolean
|
|
50
|
+
}) {
|
|
51
|
+
const Comp = asChild ? Slot.Root : "button"
|
|
52
|
+
|
|
53
|
+
return (
|
|
54
|
+
<Comp
|
|
55
|
+
data-slot="button"
|
|
56
|
+
data-variant={variant}
|
|
57
|
+
data-size={size}
|
|
58
|
+
className={cn(buttonVariants({ variant, size, className }))}
|
|
59
|
+
{...props}
|
|
60
|
+
/>
|
|
61
|
+
)
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export { Button, buttonVariants }
|