@hienlh/ppm 0.13.67 → 0.13.69

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 (37) hide show
  1. package/CHANGELOG.md +10 -0
  2. package/assets/skills/ppm/SKILL.md +1 -1
  3. package/assets/skills/ppm/references/http-api.md +3 -1
  4. package/dist/web/assets/{audio-preview-Iq-XRBGw.js → audio-preview-C8NNPTUn.js} +1 -1
  5. package/dist/web/assets/{chat-tab-DkVXRD9e.js → chat-tab-Cdu1qwBF.js} +3 -3
  6. package/dist/web/assets/{code-editor-M6wHw8AZ.js → code-editor-D7wfu-4O.js} +2 -2
  7. package/dist/web/assets/{conflict-editor-D_8t44Wi.js → conflict-editor-C4b6hljX.js} +1 -1
  8. package/dist/web/assets/{database-viewer-Cj5yCn4w.js → database-viewer-CHGY2nqV.js} +1 -1
  9. package/dist/web/assets/{diff-viewer-BgPv67fJ.js → diff-viewer-DHaCYCXp.js} +1 -1
  10. package/dist/web/assets/{docx-preview-BbmDvXdS.js → docx-preview-RrQCPnLk.js} +1 -1
  11. package/dist/web/assets/{extension-webview-CP_AtfYs.js → extension-webview-DlbHEkiH.js} +1 -1
  12. package/dist/web/assets/{git-log-panel-DPRoZgWG.js → git-log-panel-D-Ntnv3m.js} +1 -1
  13. package/dist/web/assets/{glide-data-grid-BrtUKC3w.js → glide-data-grid-BzsZNd19.js} +1 -1
  14. package/dist/web/assets/{image-preview-BFj-ipom.js → image-preview-Dc6CL-hL.js} +1 -1
  15. package/dist/web/assets/index-By8-648j.js +27 -0
  16. package/dist/web/assets/keybindings-store-ULrepar2.js +1 -0
  17. package/dist/web/assets/{markdown-renderer-B63eYfrn.js → markdown-renderer-DJIOhSF1.js} +1 -1
  18. package/dist/web/assets/notification-store-xdIEKclm.js +1 -0
  19. package/dist/web/assets/{pdf-preview-JOwOGTIk.js → pdf-preview-CLRIg41K.js} +1 -1
  20. package/dist/web/assets/{port-forwarding-tab-DJRRbLGF.js → port-forwarding-tab-BnTIZHAc.js} +1 -1
  21. package/dist/web/assets/{postgres-viewer-AIOBOfCg.js → postgres-viewer-DOdXyW0T.js} +1 -1
  22. package/dist/web/assets/{settings-tab-BMHf9pO5.js → settings-tab-CujGYYDD.js} +1 -1
  23. package/dist/web/assets/{sql-query-editor-Dw9UvzWt.js → sql-query-editor-BCztpoy4.js} +1 -1
  24. package/dist/web/assets/{sqlite-viewer-HusTxs1Z.js → sqlite-viewer-71ij4M_o.js} +1 -1
  25. package/dist/web/assets/{system-monitor-tab-BNJIkOan.js → system-monitor-tab-Br7LiuTx.js} +1 -1
  26. package/dist/web/assets/{terminal-tab-W1VShnP7.js → terminal-tab-D9_-ww9U.js} +1 -1
  27. package/dist/web/assets/{video-preview-BPAYbuvs.js → video-preview-CgV_9kiy.js} +1 -1
  28. package/dist/web/index.html +1 -1
  29. package/dist/web/sw.js +1 -1
  30. package/package.json +1 -1
  31. package/src/server/routes/projects.ts +37 -0
  32. package/src/services/extension.service.ts +9 -1
  33. package/src/services/git.service.ts +15 -0
  34. package/src/web/components/layout/add-project-form.tsx +213 -70
  35. package/dist/web/assets/index-CJZZ6v1o.js +0 -27
  36. package/dist/web/assets/keybindings-store-BOV4khyp.js +0 -1
  37. package/dist/web/assets/notification-store-BklO85um.js +0 -1
@@ -1,7 +1,7 @@
1
1
  import { resolve } from "node:path";
2
2
  import { existsSync } from "node:fs";
3
3
  import type { ExtensionManifest, ExtensionInfo, RpcMessage } from "../types/extension.ts";
4
- import { getExtensions, getExtensionById, insertExtension, updateExtension, getExtensionStorage, setExtensionStorageValue } from "./db.service.ts";
4
+ import { getExtensions, getExtensionById, insertExtension, updateExtension, deleteExtension, deleteExtensionStorage, getExtensionStorage, setExtensionStorageValue } from "./db.service.ts";
5
5
  import { contributionRegistry } from "./contribution-registry.ts";
6
6
  import { RpcChannel } from "./extension-rpc.ts";
7
7
  import { parseManifest, discoverManifests, discoverBundledManifests } from "./extension-manifest.ts";
@@ -243,7 +243,15 @@ class ExtensionService {
243
243
  });
244
244
  }
245
245
  }
246
+ // Clean up stale DB records for extensions no longer on disk
247
+ const discoveredIds = new Set(manifests.map((m) => m.id));
246
248
  for (const row of getExtensions()) {
249
+ if (!discoveredIds.has(row.id)) {
250
+ console.log(`[ExtService] startup: removing stale DB record for ${row.id}`);
251
+ deleteExtensionStorage(row.id);
252
+ deleteExtension(row.id);
253
+ continue;
254
+ }
247
255
  if (row.enabled !== 1) continue;
248
256
  console.log(`[ExtService] startup: activating ${row.id}...`);
249
257
  try { await this.activate(row.id); } catch (e) {
@@ -541,6 +541,21 @@ class GitService {
541
541
  await this.git(projectPath).raw(["worktree", "prune"]);
542
542
  }
543
543
 
544
+ /** Clone a git repo into targetDir/repoName. Returns the full cloned path. */
545
+ async cloneRepo(url: string, targetDir: string, name?: string): Promise<string> {
546
+ const repoName = name || this.parseRepoNameFromUrl(url);
547
+ if (!repoName) throw new Error("Cannot determine repo name from URL");
548
+ const fullPath = path.resolve(targetDir, repoName);
549
+ await simpleGit().clone(url, fullPath);
550
+ return fullPath;
551
+ }
552
+
553
+ private parseRepoNameFromUrl(url: string): string | null {
554
+ // Handles SSH (git@host:owner/repo.git) and HTTPS (https://host/owner/repo.git)
555
+ const match = url.match(/[/:]([^/:]+?)(?:\.git)?\s*$/);
556
+ return match?.[1] ?? null;
557
+ }
558
+
544
559
  private parseRemoteUrl(
545
560
  url: string,
546
561
  ): { host: string; owner: string; repo: string } | null {
@@ -20,17 +20,45 @@ interface AddProjectFormProps {
20
20
 
21
21
  export function AddProjectForm({ onSuccess, onCancel, footerClassName }: AddProjectFormProps) {
22
22
  const { addProject } = useProjectStore(useShallow((s) => ({ addProject: s.addProject })));
23
+
24
+ // ── Tab state ──────────────────────────────────────────────────────────────
25
+ const [tab, setTab] = useState<"local" | "clone">("local");
26
+
27
+ // ── Local folder state ─────────────────────────────────────────────────────
23
28
  const [path, setPath] = useState("");
24
29
  const [name, setName] = useState("");
25
30
  const [suggestions, setSuggestions] = useState<SuggestedDir[]>([]);
26
31
  const [showSuggestions, setShowSuggestions] = useState(false);
27
32
  const [loading, setLoading] = useState(false);
28
33
  const [submitting, setSubmitting] = useState(false);
34
+
35
+ // ── Clone state ────────────────────────────────────────────────────────────
36
+ const [gitUrl, setGitUrl] = useState("");
37
+ const [cloneDir, setCloneDir] = useState("~/Projects");
38
+ const [cloneName, setCloneName] = useState("");
39
+ const [cloning, setCloning] = useState(false);
40
+
41
+ // ── Shared ─────────────────────────────────────────────────────────────────
29
42
  const [error, setError] = useState("");
43
+
30
44
  const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
31
45
  const wrapperRef = useRef<HTMLDivElement>(null);
32
46
 
33
- // Fetch suggestions when path changes
47
+ // Load last clone dir on mount
48
+ useEffect(() => {
49
+ api.get<{ dir: string | null }>("/api/projects/last-clone-dir")
50
+ .then((res) => { if (res?.dir) setCloneDir(res.dir); })
51
+ .catch(() => {});
52
+ }, []);
53
+
54
+ // Auto-parse repo name from Git URL
55
+ useEffect(() => {
56
+ if (!gitUrl.trim()) { setCloneName(""); return; }
57
+ const match = gitUrl.match(/[/:]([^/:]+?)(?:\.git)?\s*$/);
58
+ if (match?.[1]) setCloneName(match[1]);
59
+ }, [gitUrl]);
60
+
61
+ // Fetch suggestions when local path changes
34
62
  useEffect(() => {
35
63
  if (debounceRef.current) clearTimeout(debounceRef.current);
36
64
  if (!path.trim()) { setSuggestions([]); setShowSuggestions(false); return; }
@@ -80,84 +108,199 @@ export function AddProjectForm({ onSuccess, onCancel, footerClassName }: AddProj
80
108
  }
81
109
  }
82
110
 
111
+ async function handleClone(e?: React.FormEvent) {
112
+ e?.preventDefault();
113
+ if (!gitUrl.trim()) { setError("Git URL is required"); return; }
114
+ if (!cloneDir.trim()) { setError("Clone directory is required"); return; }
115
+ setError("");
116
+ setCloning(true);
117
+ try {
118
+ const result = await api.post<{ path: string; project: unknown }>(
119
+ "/api/projects/git/clone",
120
+ { url: gitUrl.trim(), targetDir: cloneDir.trim(), name: cloneName.trim() || undefined },
121
+ );
122
+ // Server already added project — refresh store and select it
123
+ const { fetchProjects, setActiveProject } = useProjectStore.getState();
124
+ await fetchProjects();
125
+ const projects = useProjectStore.getState().projects;
126
+ const newProj = projects.find((p) => p.path === result.path);
127
+ if (newProj) setActiveProject(newProj);
128
+ onSuccess();
129
+ } catch (err) {
130
+ setError(err instanceof Error ? err.message : "Clone failed");
131
+ } finally {
132
+ setCloning(false);
133
+ }
134
+ }
135
+
83
136
  return (
84
- <form onSubmit={handleSubmit} className="flex flex-col gap-3">
85
- {/* Path input with suggestions */}
86
- <div ref={wrapperRef} className="relative">
87
- <label className="block text-xs font-medium text-foreground mb-1">Project path</label>
88
- <div className="flex gap-1.5 items-center">
89
- <div className="relative flex items-center flex-1">
90
- <FolderOpen className="absolute left-2.5 size-3.5 text-text-subtle pointer-events-none" />
137
+ <div className="flex flex-col gap-3">
138
+ {/* Tab bar */}
139
+ <div className="flex gap-1 border-b border-border">
140
+ {(["local", "clone"] as const).map((t) => (
141
+ <button
142
+ key={t}
143
+ type="button"
144
+ onClick={() => { setTab(t); setError(""); }}
145
+ className={cn(
146
+ "px-3 py-1.5 text-sm font-medium border-b-2 -mb-px transition-colors",
147
+ tab === t
148
+ ? "border-primary text-foreground"
149
+ : "border-transparent text-text-secondary hover:text-foreground",
150
+ )}
151
+ >
152
+ {t === "local" ? "Local folder" : "Clone from Git"}
153
+ </button>
154
+ ))}
155
+ </div>
156
+
157
+ {tab === "local" ? (
158
+ <form onSubmit={handleSubmit} className="flex flex-col gap-3">
159
+ {/* Path input with suggestions */}
160
+ <div ref={wrapperRef} className="relative">
161
+ <label className="block text-xs font-medium text-foreground mb-1">Project path</label>
162
+ <div className="flex gap-1.5 items-center">
163
+ <div className="relative flex items-center flex-1">
164
+ <FolderOpen className="absolute left-2.5 size-3.5 text-text-subtle pointer-events-none" />
165
+ <input
166
+ type="text"
167
+ value={path}
168
+ onChange={(e) => { setPath(e.target.value); setError(""); }}
169
+ onFocus={() => suggestions.length > 0 && setShowSuggestions(true)}
170
+ placeholder="/path/to/project"
171
+ className="w-full pl-8 pr-3 py-2 rounded-md border border-border bg-background text-sm focus:outline-none focus:ring-1 focus:ring-primary"
172
+ autoFocus
173
+ autoComplete="off"
174
+ />
175
+ {loading && <Loader2 className="absolute right-2.5 size-3.5 text-text-subtle animate-spin" />}
176
+ </div>
177
+ <BrowseButton
178
+ mode="folder"
179
+ onSelect={(selectedPath) => {
180
+ setPath(selectedPath);
181
+ if (!name) setName(selectedPath.split("/").pop() ?? "");
182
+ setError("");
183
+ }}
184
+ />
185
+ </div>
186
+
187
+ {/* Suggestions dropdown */}
188
+ {showSuggestions && suggestions.length > 0 && (
189
+ <div className="absolute top-full left-0 right-0 mt-1 z-10 bg-popover border border-border rounded-md shadow-md max-h-48 overflow-y-auto">
190
+ {suggestions.map((dir) => (
191
+ <button
192
+ key={dir.path}
193
+ type="button"
194
+ onMouseDown={() => selectSuggestion(dir)}
195
+ className="w-full flex flex-col items-start px-3 py-2 text-left hover:bg-accent/50 transition-colors"
196
+ >
197
+ <span className="text-sm font-medium truncate w-full">{dir.name}</span>
198
+ <span className="text-xs text-text-subtle truncate w-full">{dir.path}</span>
199
+ </button>
200
+ ))}
201
+ </div>
202
+ )}
203
+ </div>
204
+
205
+ {/* Optional name */}
206
+ <div>
207
+ <label className="block text-xs font-medium text-foreground mb-1">Display name <span className="text-muted-foreground">(optional)</span></label>
208
+ <input
209
+ type="text"
210
+ value={name}
211
+ onChange={(e) => setName(e.target.value)}
212
+ placeholder="my-project"
213
+ className="w-full px-3 py-2 rounded-md border border-border bg-background text-sm focus:outline-none focus:ring-1 focus:ring-primary"
214
+ />
215
+ </div>
216
+
217
+ {error && <p className="text-xs text-destructive">{error}</p>}
218
+
219
+ <div className={cn("flex justify-end gap-2 pt-1", footerClassName)}>
220
+ <button
221
+ type="button"
222
+ onClick={onCancel}
223
+ className="px-3 py-1.5 text-sm text-text-secondary hover:text-foreground transition-colors"
224
+ >
225
+ Cancel
226
+ </button>
227
+ <button
228
+ type="submit"
229
+ disabled={submitting || !path.trim()}
230
+ className="px-3 py-1.5 text-sm bg-primary text-white rounded-md hover:bg-primary/90 transition-colors disabled:opacity-50"
231
+ >
232
+ {submitting ? "Adding…" : "Add Project"}
233
+ </button>
234
+ </div>
235
+ </form>
236
+ ) : (
237
+ <form onSubmit={handleClone} className="flex flex-col gap-3">
238
+ {/* Git URL */}
239
+ <div>
240
+ <label className="block text-xs font-medium text-foreground mb-1">Git URL</label>
91
241
  <input
92
242
  type="text"
93
- value={path}
94
- onChange={(e) => { setPath(e.target.value); setError(""); }}
95
- onFocus={() => suggestions.length > 0 && setShowSuggestions(true)}
96
- placeholder="/path/to/project"
97
- className="w-full pl-8 pr-3 py-2 rounded-md border border-border bg-background text-sm focus:outline-none focus:ring-1 focus:ring-primary"
243
+ value={gitUrl}
244
+ onChange={(e) => { setGitUrl(e.target.value); setError(""); }}
245
+ placeholder="https://github.com/user/repo.git"
246
+ className="w-full px-3 py-2 rounded-md border border-border bg-background text-sm focus:outline-none focus:ring-1 focus:ring-primary"
98
247
  autoFocus
99
248
  autoComplete="off"
100
249
  />
101
- {loading && <Loader2 className="absolute right-2.5 size-3.5 text-text-subtle animate-spin" />}
102
250
  </div>
103
- <BrowseButton
104
- mode="folder"
105
- onSelect={(selectedPath) => {
106
- setPath(selectedPath);
107
- if (!name) setName(selectedPath.split("/").pop() ?? "");
108
- setError("");
109
- }}
110
- />
111
- </div>
112
-
113
- {/* Suggestions dropdown */}
114
- {showSuggestions && suggestions.length > 0 && (
115
- <div className="absolute top-full left-0 right-0 mt-1 z-10 bg-popover border border-border rounded-md shadow-md max-h-48 overflow-y-auto">
116
- {suggestions.map((dir) => (
117
- <button
118
- key={dir.path}
119
- type="button"
120
- onMouseDown={() => selectSuggestion(dir)}
121
- className="w-full flex flex-col items-start px-3 py-2 text-left hover:bg-accent/50 transition-colors"
122
- >
123
- <span className="text-sm font-medium truncate w-full">{dir.name}</span>
124
- <span className="text-xs text-text-subtle truncate w-full">{dir.path}</span>
125
- </button>
126
- ))}
251
+
252
+ {/* Clone to directory */}
253
+ <div>
254
+ <label className="block text-xs font-medium text-foreground mb-1">Clone to</label>
255
+ <div className="flex gap-1.5 items-center">
256
+ <input
257
+ type="text"
258
+ value={cloneDir}
259
+ onChange={(e) => { setCloneDir(e.target.value); setError(""); }}
260
+ placeholder="~/Projects"
261
+ className="flex-1 px-3 py-2 rounded-md border border-border bg-background text-sm focus:outline-none focus:ring-1 focus:ring-primary"
262
+ />
263
+ <BrowseButton
264
+ mode="folder"
265
+ onSelect={(p) => { setCloneDir(p); setError(""); }}
266
+ />
267
+ </div>
127
268
  </div>
128
- )}
129
- </div>
130
269
 
131
- {/* Optional name */}
132
- <div>
133
- <label className="block text-xs font-medium text-foreground mb-1">Display name <span className="text-muted-foreground">(optional)</span></label>
134
- <input
135
- type="text"
136
- value={name}
137
- onChange={(e) => setName(e.target.value)}
138
- placeholder="my-project"
139
- className="w-full px-3 py-2 rounded-md border border-border bg-background text-sm focus:outline-none focus:ring-1 focus:ring-primary"
140
- />
141
- </div>
270
+ {/* Repo name override */}
271
+ <div>
272
+ <label className="block text-xs font-medium text-foreground mb-1">
273
+ Name <span className="text-muted-foreground">(auto-parsed from URL)</span>
274
+ </label>
275
+ <input
276
+ type="text"
277
+ value={cloneName}
278
+ onChange={(e) => setCloneName(e.target.value)}
279
+ placeholder="repo-name"
280
+ className="w-full px-3 py-2 rounded-md border border-border bg-background text-sm focus:outline-none focus:ring-1 focus:ring-primary"
281
+ />
282
+ </div>
142
283
 
143
- {error && <p className="text-xs text-destructive">{error}</p>}
144
-
145
- <div className={cn("flex justify-end gap-2 pt-1", footerClassName)}>
146
- <button
147
- type="button"
148
- onClick={onCancel}
149
- className="px-3 py-1.5 text-sm text-text-secondary hover:text-foreground transition-colors"
150
- >
151
- Cancel
152
- </button>
153
- <button
154
- type="submit"
155
- disabled={submitting || !path.trim()}
156
- className="px-3 py-1.5 text-sm bg-primary text-white rounded-md hover:bg-primary/90 transition-colors disabled:opacity-50"
157
- >
158
- {submitting ? "Adding…" : "Add Project"}
159
- </button>
160
- </div>
161
- </form>
284
+ {error && <p className="text-xs text-destructive">{error}</p>}
285
+
286
+ <div className={cn("flex justify-end gap-2 pt-1", footerClassName)}>
287
+ <button
288
+ type="button"
289
+ onClick={onCancel}
290
+ className="px-3 py-1.5 text-sm text-text-secondary hover:text-foreground transition-colors"
291
+ >
292
+ Cancel
293
+ </button>
294
+ <button
295
+ type="submit"
296
+ disabled={cloning || !gitUrl.trim() || !cloneDir.trim()}
297
+ className="px-3 py-1.5 text-sm bg-primary text-white rounded-md hover:bg-primary/90 transition-colors disabled:opacity-50"
298
+ >
299
+ {cloning ? <><Loader2 className="inline size-3.5 animate-spin mr-1.5" />Cloning…</> : "Clone & Add"}
300
+ </button>
301
+ </div>
302
+ </form>
303
+ )}
304
+ </div>
162
305
  );
163
306
  }