@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,261 @@
|
|
|
1
|
+
import {
|
|
2
|
+
existsSync,
|
|
3
|
+
readdirSync,
|
|
4
|
+
statSync,
|
|
5
|
+
readFileSync,
|
|
6
|
+
writeFileSync,
|
|
7
|
+
mkdirSync,
|
|
8
|
+
unlinkSync,
|
|
9
|
+
rmSync,
|
|
10
|
+
renameSync,
|
|
11
|
+
} from "node:fs";
|
|
12
|
+
import { resolve, relative, basename, dirname, join, normalize } from "node:path";
|
|
13
|
+
import type { FileNode } from "../types/project.ts";
|
|
14
|
+
|
|
15
|
+
/** Directories/files excluded from tree listing */
|
|
16
|
+
const EXCLUDED_NAMES = new Set([".git", "node_modules", ".env"]);
|
|
17
|
+
|
|
18
|
+
/** Max buffer size for binary detection (first 8KB) */
|
|
19
|
+
const BINARY_CHECK_BYTES = 8192;
|
|
20
|
+
|
|
21
|
+
class FileService {
|
|
22
|
+
/**
|
|
23
|
+
* Validate that `targetPath` is inside `projectPath`.
|
|
24
|
+
* Blocks path traversal (../) and access outside project root.
|
|
25
|
+
*/
|
|
26
|
+
private assertWithinProject(targetPath: string, projectPath: string): void {
|
|
27
|
+
const normalizedTarget = normalize(resolve(projectPath, targetPath));
|
|
28
|
+
const normalizedProject = normalize(projectPath);
|
|
29
|
+
if (
|
|
30
|
+
!normalizedTarget.startsWith(normalizedProject + "/") &&
|
|
31
|
+
normalizedTarget !== normalizedProject
|
|
32
|
+
) {
|
|
33
|
+
throw new SecurityError("Path traversal not allowed");
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Resolve a relative file path against a project root, with security check */
|
|
38
|
+
private resolveSafe(projectPath: string, filePath: string): string {
|
|
39
|
+
this.assertWithinProject(filePath, projectPath);
|
|
40
|
+
return resolve(projectPath, filePath);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Check if a name is in the exclusion list */
|
|
44
|
+
private isExcluded(name: string): boolean {
|
|
45
|
+
return EXCLUDED_NAMES.has(name);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Build a recursive file tree for a project directory */
|
|
49
|
+
getTree(projectPath: string, depth = 3): FileNode[] {
|
|
50
|
+
return this.buildTree(projectPath, projectPath, 0, depth);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
private buildTree(
|
|
54
|
+
rootPath: string,
|
|
55
|
+
dirPath: string,
|
|
56
|
+
currentDepth: number,
|
|
57
|
+
maxDepth: number,
|
|
58
|
+
): FileNode[] {
|
|
59
|
+
if (currentDepth > maxDepth) return [];
|
|
60
|
+
if (!existsSync(dirPath)) return [];
|
|
61
|
+
|
|
62
|
+
const entries = readdirSync(dirPath, { withFileTypes: true });
|
|
63
|
+
const nodes: FileNode[] = [];
|
|
64
|
+
|
|
65
|
+
for (const entry of entries) {
|
|
66
|
+
if (this.isExcluded(entry.name)) continue;
|
|
67
|
+
// Skip hidden files at root level (like .env.local etc.)
|
|
68
|
+
if (entry.name.startsWith(".env")) continue;
|
|
69
|
+
|
|
70
|
+
const fullPath = join(dirPath, entry.name);
|
|
71
|
+
const relPath = relative(rootPath, fullPath);
|
|
72
|
+
|
|
73
|
+
try {
|
|
74
|
+
const stat = statSync(fullPath);
|
|
75
|
+
const node: FileNode = {
|
|
76
|
+
name: entry.name,
|
|
77
|
+
path: relPath,
|
|
78
|
+
type: entry.isDirectory() ? "directory" : "file",
|
|
79
|
+
size: entry.isFile() ? stat.size : undefined,
|
|
80
|
+
modified: stat.mtime.toISOString(),
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
if (entry.isDirectory()) {
|
|
84
|
+
node.children = this.buildTree(
|
|
85
|
+
rootPath,
|
|
86
|
+
fullPath,
|
|
87
|
+
currentDepth + 1,
|
|
88
|
+
maxDepth,
|
|
89
|
+
);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
nodes.push(node);
|
|
93
|
+
} catch {
|
|
94
|
+
// Permission denied or broken symlink — skip
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// Sort: directories first, then alphabetically
|
|
99
|
+
nodes.sort((a, b) => {
|
|
100
|
+
if (a.type !== b.type) return a.type === "directory" ? -1 : 1;
|
|
101
|
+
return a.name.localeCompare(b.name);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
return nodes;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** Read file content with encoding detection */
|
|
108
|
+
readFile(
|
|
109
|
+
projectPath: string,
|
|
110
|
+
filePath: string,
|
|
111
|
+
): { content: string; encoding: string } {
|
|
112
|
+
const absPath = this.resolveSafe(projectPath, filePath);
|
|
113
|
+
this.blockSensitive(filePath);
|
|
114
|
+
|
|
115
|
+
if (!existsSync(absPath)) {
|
|
116
|
+
throw new NotFoundError(`File not found: ${filePath}`);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const stat = statSync(absPath);
|
|
120
|
+
if (stat.isDirectory()) {
|
|
121
|
+
throw new ValidationError("Cannot read a directory");
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// Binary detection: check for null bytes in first chunk
|
|
125
|
+
const buffer = readFileSync(absPath);
|
|
126
|
+
const checkSlice = buffer.subarray(0, BINARY_CHECK_BYTES);
|
|
127
|
+
if (checkSlice.includes(0)) {
|
|
128
|
+
return { content: buffer.toString("base64"), encoding: "base64" };
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
return { content: buffer.toString("utf-8"), encoding: "utf-8" };
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** Write content to a file */
|
|
135
|
+
writeFile(projectPath: string, filePath: string, content: string): void {
|
|
136
|
+
const absPath = this.resolveSafe(projectPath, filePath);
|
|
137
|
+
this.blockSensitive(filePath);
|
|
138
|
+
|
|
139
|
+
// Ensure parent directory exists
|
|
140
|
+
const dir = dirname(absPath);
|
|
141
|
+
if (!existsSync(dir)) {
|
|
142
|
+
mkdirSync(dir, { recursive: true });
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
writeFileSync(absPath, content, "utf-8");
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/** Create a file or directory */
|
|
149
|
+
createFile(
|
|
150
|
+
projectPath: string,
|
|
151
|
+
filePath: string,
|
|
152
|
+
type: "file" | "directory",
|
|
153
|
+
): void {
|
|
154
|
+
const absPath = this.resolveSafe(projectPath, filePath);
|
|
155
|
+
this.blockSensitive(filePath);
|
|
156
|
+
|
|
157
|
+
if (existsSync(absPath)) {
|
|
158
|
+
throw new ValidationError(`Already exists: ${filePath}`);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
if (type === "directory") {
|
|
162
|
+
mkdirSync(absPath, { recursive: true });
|
|
163
|
+
} else {
|
|
164
|
+
const dir = dirname(absPath);
|
|
165
|
+
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
|
|
166
|
+
writeFileSync(absPath, "", "utf-8");
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/** Delete a file or directory */
|
|
171
|
+
deleteFile(projectPath: string, filePath: string): void {
|
|
172
|
+
const absPath = this.resolveSafe(projectPath, filePath);
|
|
173
|
+
this.blockSensitive(filePath);
|
|
174
|
+
|
|
175
|
+
if (!existsSync(absPath)) {
|
|
176
|
+
throw new NotFoundError(`Not found: ${filePath}`);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
const stat = statSync(absPath);
|
|
180
|
+
if (stat.isDirectory()) {
|
|
181
|
+
rmSync(absPath, { recursive: true, force: true });
|
|
182
|
+
} else {
|
|
183
|
+
unlinkSync(absPath);
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/** Rename a file or directory */
|
|
188
|
+
renameFile(
|
|
189
|
+
projectPath: string,
|
|
190
|
+
oldPath: string,
|
|
191
|
+
newPath: string,
|
|
192
|
+
): void {
|
|
193
|
+
const absOld = this.resolveSafe(projectPath, oldPath);
|
|
194
|
+
const absNew = this.resolveSafe(projectPath, newPath);
|
|
195
|
+
this.blockSensitive(oldPath);
|
|
196
|
+
this.blockSensitive(newPath);
|
|
197
|
+
|
|
198
|
+
if (!existsSync(absOld)) {
|
|
199
|
+
throw new NotFoundError(`Not found: ${oldPath}`);
|
|
200
|
+
}
|
|
201
|
+
if (existsSync(absNew)) {
|
|
202
|
+
throw new ValidationError(`Already exists: ${newPath}`);
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
// Ensure parent dir of new path exists
|
|
206
|
+
const dir = dirname(absNew);
|
|
207
|
+
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
|
|
208
|
+
|
|
209
|
+
renameSync(absOld, absNew);
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/** Move a file or directory to a new location */
|
|
213
|
+
moveFile(
|
|
214
|
+
projectPath: string,
|
|
215
|
+
source: string,
|
|
216
|
+
destination: string,
|
|
217
|
+
): void {
|
|
218
|
+
// Move is functionally the same as rename
|
|
219
|
+
this.renameFile(projectPath, source, destination);
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/** Block access to sensitive paths (.git/, .env*) */
|
|
223
|
+
private blockSensitive(filePath: string): void {
|
|
224
|
+
const normalized = normalize(filePath);
|
|
225
|
+
const parts = normalized.split("/");
|
|
226
|
+
for (const part of parts) {
|
|
227
|
+
if (part === ".git" || part === "node_modules") {
|
|
228
|
+
throw new SecurityError(`Access denied: ${filePath}`);
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
// Block .env files
|
|
232
|
+
const file = basename(normalized);
|
|
233
|
+
if (file.startsWith(".env")) {
|
|
234
|
+
throw new SecurityError(`Access denied: ${filePath}`);
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/** Custom error classes for proper HTTP status mapping */
|
|
240
|
+
export class SecurityError extends Error {
|
|
241
|
+
constructor(message: string) {
|
|
242
|
+
super(message);
|
|
243
|
+
this.name = "SecurityError";
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
export class NotFoundError extends Error {
|
|
248
|
+
constructor(message: string) {
|
|
249
|
+
super(message);
|
|
250
|
+
this.name = "NotFoundError";
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
export class ValidationError extends Error {
|
|
255
|
+
constructor(message: string) {
|
|
256
|
+
super(message);
|
|
257
|
+
this.name = "ValidationError";
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
export const fileService = new FileService();
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import { resolve, basename } from "node:path";
|
|
2
|
+
import { readdirSync, existsSync, statSync, lstatSync } from "node:fs";
|
|
3
|
+
import { homedir } from "node:os";
|
|
4
|
+
|
|
5
|
+
export interface GitDir {
|
|
6
|
+
path: string;
|
|
7
|
+
name: string;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/** Directories to never descend into (perf + irrelevant). */
|
|
11
|
+
const SKIP_DIRS = new Set([
|
|
12
|
+
"node_modules", ".git", ".hg", ".svn", "vendor", "dist", "build",
|
|
13
|
+
".cache", ".npm", ".pnpm", ".yarn", "__pycache__", ".venv", "venv",
|
|
14
|
+
".Trash", "Library", "Applications", ".local", ".config",
|
|
15
|
+
]);
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* In-memory cache: root path → list of git dirs found under it.
|
|
19
|
+
* Populated on first deep scan, reused for subsequent queries.
|
|
20
|
+
*/
|
|
21
|
+
const cache = new Map<string, { dirs: GitDir[]; timestamp: number }>();
|
|
22
|
+
|
|
23
|
+
/** Cache TTL: 5 minutes */
|
|
24
|
+
const CACHE_TTL = 5 * 60 * 1000;
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Recursively find all directories containing `.git` under `root`.
|
|
28
|
+
* Once a .git dir is found, we don't descend further into it (projects don't nest).
|
|
29
|
+
*/
|
|
30
|
+
function scanGitDirs(root: string, maxDepth: number): GitDir[] {
|
|
31
|
+
const results: GitDir[] = [];
|
|
32
|
+
|
|
33
|
+
function walk(dir: string, depth: number) {
|
|
34
|
+
if (depth > maxDepth) return;
|
|
35
|
+
|
|
36
|
+
let entries: string[];
|
|
37
|
+
try {
|
|
38
|
+
entries = readdirSync(dir);
|
|
39
|
+
} catch {
|
|
40
|
+
return;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
for (const entry of entries) {
|
|
44
|
+
if (entry.startsWith(".") || SKIP_DIRS.has(entry)) continue;
|
|
45
|
+
const full = resolve(dir, entry);
|
|
46
|
+
try {
|
|
47
|
+
// Skip symlinks to avoid cycles
|
|
48
|
+
if (lstatSync(full).isSymbolicLink()) continue;
|
|
49
|
+
if (!statSync(full).isDirectory()) continue;
|
|
50
|
+
|
|
51
|
+
if (existsSync(resolve(full, ".git"))) {
|
|
52
|
+
results.push({ path: full, name: entry });
|
|
53
|
+
// Don't recurse into a git repo — nested repos are rare
|
|
54
|
+
} else {
|
|
55
|
+
walk(full, depth + 1);
|
|
56
|
+
}
|
|
57
|
+
} catch { /* skip unreadable */ }
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// Check if root itself is a git repo
|
|
62
|
+
if (existsSync(resolve(root, ".git"))) {
|
|
63
|
+
results.push({ path: root, name: basename(root) });
|
|
64
|
+
} else {
|
|
65
|
+
walk(root, 0);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
return results;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Get git directories under `root`, using cache when available.
|
|
73
|
+
* @param root Directory to search (defaults to home dir)
|
|
74
|
+
* @param maxDepth Max recursion depth (default 4)
|
|
75
|
+
*/
|
|
76
|
+
export function getGitDirs(root?: string, maxDepth = 4): GitDir[] {
|
|
77
|
+
const dir = resolve(root ?? homedir());
|
|
78
|
+
|
|
79
|
+
if (!existsSync(dir) || !statSync(dir).isDirectory()) {
|
|
80
|
+
return [];
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const cached = cache.get(dir);
|
|
84
|
+
if (cached && Date.now() - cached.timestamp < CACHE_TTL) {
|
|
85
|
+
return cached.dirs;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const dirs = scanGitDirs(dir, maxDepth);
|
|
89
|
+
cache.set(dir, { dirs, timestamp: Date.now() });
|
|
90
|
+
return dirs;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Filter cached/scanned git dirs by a query string (fuzzy path match).
|
|
95
|
+
*/
|
|
96
|
+
export function searchGitDirs(query: string, root?: string, maxDepth = 4): GitDir[] {
|
|
97
|
+
const all = getGitDirs(root, maxDepth);
|
|
98
|
+
if (!query) return all;
|
|
99
|
+
const q = query.toLowerCase();
|
|
100
|
+
return all.filter(
|
|
101
|
+
(d) => d.path.toLowerCase().includes(q) || d.name.toLowerCase().includes(q),
|
|
102
|
+
);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** Invalidate cache for a specific root (e.g. after adding a project). */
|
|
106
|
+
export function invalidateGitDirCache(root?: string): void {
|
|
107
|
+
if (root) {
|
|
108
|
+
cache.delete(resolve(root));
|
|
109
|
+
} else {
|
|
110
|
+
cache.clear();
|
|
111
|
+
}
|
|
112
|
+
}
|