@hienlh/ppm 0.13.66 → 0.13.68

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 (48) hide show
  1. package/CHANGELOG.md +14 -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-B8XiU4Bw.js → audio-preview-DLz0dEw6.js} +1 -1
  5. package/dist/web/assets/{chat-tab-B1m7T_2n.js → chat-tab-B0L_i2ls.js} +3 -3
  6. package/dist/web/assets/{code-editor-CQSDgP7X.js → code-editor-BEdecTYr.js} +2 -2
  7. package/dist/web/assets/{conflict-editor-BPjmtXlC.js → conflict-editor-CkUhGJsN.js} +1 -1
  8. package/dist/web/assets/{database-viewer-Cl31pR9W.js → database-viewer-CRBrhKbW.js} +1 -1
  9. package/dist/web/assets/diff-viewer-DcyIipTd.js +4 -0
  10. package/dist/web/assets/{docx-preview-D_P_e_0O.js → docx-preview-m0bGH6IL.js} +1 -1
  11. package/dist/web/assets/extension-webview-D50XqcZE.js +3 -0
  12. package/dist/web/assets/{git-log-panel-CAa4j8NA.js → git-log-panel-CyYHsqIb.js} +1 -1
  13. package/dist/web/assets/{glide-data-grid-DbtdLkFk.js → glide-data-grid-Dn8CvXh7.js} +1 -1
  14. package/dist/web/assets/{image-preview-DjWCljN-.js → image-preview-DTfGdGj8.js} +1 -1
  15. package/dist/web/assets/index-BgHCBGwE.js +27 -0
  16. package/dist/web/assets/keybindings-store-BhM4ou6C.js +1 -0
  17. package/dist/web/assets/{markdown-renderer-BojoStRy.js → markdown-renderer-940Y1Maq.js} +1 -1
  18. package/dist/web/assets/notification-store-DWsPQ2XR.js +1 -0
  19. package/dist/web/assets/{pdf-preview-19LY16zS.js → pdf-preview-D8lVKDTs.js} +1 -1
  20. package/dist/web/assets/{port-forwarding-tab-DIqVwGrL.js → port-forwarding-tab-B-hu3lGI.js} +1 -1
  21. package/dist/web/assets/{postgres-viewer-DOTykgcg.js → postgres-viewer-DfCzELiK.js} +1 -1
  22. package/dist/web/assets/{settings-tab-C5S_iYSH.js → settings-tab-BNGqONuA.js} +1 -1
  23. package/dist/web/assets/{sql-query-editor-BsxW0lTw.js → sql-query-editor-D3Jyjw6V.js} +1 -1
  24. package/dist/web/assets/{sqlite-viewer-Fq4NnQg6.js → sqlite-viewer-B5Mrzox5.js} +1 -1
  25. package/dist/web/assets/system-monitor-tab-_ZjrnvgM.js +1 -0
  26. package/dist/web/assets/{terminal-tab-Lu2U4vpg.js → terminal-tab-DNswsdLl.js} +1 -1
  27. package/dist/web/assets/{video-preview-8Vrdwy25.js → video-preview-CaB29_z0.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/git.ts +2 -1
  32. package/src/server/routes/projects.ts +37 -0
  33. package/src/services/git.service.ts +32 -7
  34. package/src/services/resource-monitor-utils.ts +8 -5
  35. package/src/services/resource-monitor.service.ts +2 -1
  36. package/src/web/components/editor/diff-viewer.tsx +1 -0
  37. package/src/web/components/extensions/extension-webview.tsx +2 -2
  38. package/src/web/components/layout/add-project-form.tsx +213 -70
  39. package/src/web/components/system/system-monitor-group-row.tsx +27 -7
  40. package/src/web/components/system/system-monitor-tab.tsx +1 -1
  41. package/src/web/hooks/use-extension-ws.ts +16 -3
  42. package/src/web/hooks/use-resource-monitor.ts +1 -1
  43. package/dist/web/assets/diff-viewer-sbO35hMr.js +0 -4
  44. package/dist/web/assets/extension-webview-B2Q7T_NQ.js +0 -3
  45. package/dist/web/assets/index-PZd81rhr.js +0 -27
  46. package/dist/web/assets/keybindings-store-DZjJtyij.js +0 -1
  47. package/dist/web/assets/notification-store-CgsqI4c0.js +0 -1
  48. package/dist/web/assets/system-monitor-tab-C51mwQcv.js +0 -1
@@ -132,9 +132,9 @@ class GitService {
132
132
  projectPath: string,
133
133
  filePath: string,
134
134
  ref: string = "HEAD",
135
+ ref2?: string,
135
136
  ): Promise<{ original: string; modified: string }> {
136
137
  const git = this.git(projectPath);
137
- const absPath = path.resolve(projectPath, filePath);
138
138
 
139
139
  let original = "";
140
140
  try {
@@ -145,12 +145,22 @@ class GitService {
145
145
  }
146
146
 
147
147
  let modified = "";
148
- try {
149
- const f = Bun.file(absPath);
150
- if (await f.exists()) modified = await f.text();
151
- } catch {
152
- // File missing on disk (deleted) → empty modified
153
- modified = "";
148
+ if (ref2) {
149
+ // Commit-to-commit diff: read modified from git object store
150
+ try {
151
+ modified = await git.show([`${ref2}:${filePath}`]);
152
+ } catch {
153
+ modified = "";
154
+ }
155
+ } else {
156
+ // Working tree diff: read from disk
157
+ try {
158
+ const absPath = path.resolve(projectPath, filePath);
159
+ const f = Bun.file(absPath);
160
+ if (await f.exists()) modified = await f.text();
161
+ } catch {
162
+ modified = "";
163
+ }
154
164
  }
155
165
 
156
166
  return { original, modified };
@@ -531,6 +541,21 @@ class GitService {
531
541
  await this.git(projectPath).raw(["worktree", "prune"]);
532
542
  }
533
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
+
534
559
  private parseRemoteUrl(
535
560
  url: string,
536
561
  ): { host: string; owner: string; repo: string } | null {
@@ -25,23 +25,25 @@ function categorize(cmd: string): ResourceGroup["type"] {
25
25
 
26
26
  // ── Parser ─────────────────────────────────────────────────────────────
27
27
 
28
- /** Parse `ps -e -o pid,ppid,%cpu,rss,args` output into structured entries */
28
+ /** Parse `ps -e -o pid,ppid,%cpu,rss,etimes,args` output into structured entries */
29
29
  export function parseProcessList(stdout: string): ProcessEntry[] {
30
30
  const lines = stdout.trim().split("\n");
31
31
  if (lines.length < 2) return [];
32
32
 
33
+ const now = Date.now();
33
34
  const entries: ProcessEntry[] = [];
34
35
  for (let i = 1; i < lines.length; i++) {
35
36
  const line = lines[i]?.trim();
36
37
  if (!line) continue;
37
38
  const parts = line.split(/\s+/);
38
- if (parts.length < 5) continue;
39
+ if (parts.length < 6) continue;
39
40
 
40
41
  const pid = parseInt(parts[0]!, 10);
41
42
  const ppid = parseInt(parts[1]!, 10);
42
43
  const cpu = parseFloat(parts[2]!);
43
44
  const rssKB = parseInt(parts[3]!, 10);
44
- const command = parts.slice(4).join(" ");
45
+ const etimes = parseInt(parts[4]!, 10);
46
+ const command = parts.slice(5).join(" ");
45
47
 
46
48
  if (isNaN(pid) || pid === 0 || !command) continue;
47
49
 
@@ -50,6 +52,7 @@ export function parseProcessList(stdout: string): ProcessEntry[] {
50
52
  ppid,
51
53
  cpu: Math.round(cpu * 10) / 10,
52
54
  ramMB: Math.round((rssKB / 1024) * 10) / 10,
55
+ startedAt: isNaN(etimes) ? now : now - etimes * 1000,
53
56
  command,
54
57
  });
55
58
  }
@@ -104,7 +107,7 @@ export function groupProcesses(
104
107
  label: "PPM Server",
105
108
  cpu: serverEntry.cpu,
106
109
  ramMB: serverEntry.ramMB,
107
- processes: [{ pid: serverEntry.pid, cpu: serverEntry.cpu, ramMB: serverEntry.ramMB, command: serverEntry.command }],
110
+ processes: [{ pid: serverEntry.pid, cpu: serverEntry.cpu, ramMB: serverEntry.ramMB, startedAt: serverEntry.startedAt, command: serverEntry.command }],
108
111
  });
109
112
  }
110
113
 
@@ -130,7 +133,7 @@ export function groupProcesses(
130
133
  label: TYPE_LABELS[type],
131
134
  cpu: Math.round(procs.reduce((s, p) => s + p.cpu, 0) * 10) / 10,
132
135
  ramMB: Math.round(procs.reduce((s, p) => s + p.ramMB, 0) * 10) / 10,
133
- processes: procs.map((p) => ({ pid: p.pid, cpu: p.cpu, ramMB: p.ramMB, command: p.command })),
136
+ processes: procs.map((p) => ({ pid: p.pid, cpu: p.cpu, ramMB: p.ramMB, startedAt: p.startedAt, command: p.command })),
134
137
  });
135
138
  }
136
139
 
@@ -13,6 +13,7 @@ export interface ProcessEntry {
13
13
  ppid: number;
14
14
  cpu: number;
15
15
  ramMB: number;
16
+ startedAt: number;
16
17
  command: string;
17
18
  }
18
19
 
@@ -78,7 +79,7 @@ class ResourceMonitorService {
78
79
  private async poll() {
79
80
  try {
80
81
  const proc = Bun.spawn({
81
- cmd: ["ps", "-e", "-o", "pid,ppid,%cpu,rss,args"],
82
+ cmd: ["ps", "-e", "-o", "pid,ppid,%cpu,rss,etimes,args"],
82
83
  stdout: "pipe",
83
84
  stderr: "ignore",
84
85
  });
@@ -86,6 +86,7 @@ export function DiffViewer({ metadata }: DiffViewerProps) {
86
86
  if (filePath) {
87
87
  const params = new URLSearchParams({ file: filePath });
88
88
  if (ref1) params.set("ref", ref1);
89
+ if (ref2) params.set("ref2", ref2);
89
90
  api
90
91
  .get<{ original: string; modified: string }>(
91
92
  `${projectUrl(projectName)}/git/file-full-diff?${params}`,
@@ -83,7 +83,7 @@ export function ExtensionWebview({ metadata }: ExtensionWebviewProps) {
83
83
  }
84
84
  if (cancelled) return;
85
85
  window.dispatchEvent(new CustomEvent("ext:command:execute", {
86
- detail: { command, args },
86
+ detail: { command, args, recovery: true },
87
87
  }));
88
88
  }
89
89
 
@@ -117,7 +117,7 @@ export function ExtensionWebview({ metadata }: ExtensionWebviewProps) {
117
117
  const match = json.data?.find((p) => p.name === projectName);
118
118
  const args = match ? [match.path] : [];
119
119
  window.dispatchEvent(new CustomEvent("ext:command:execute", {
120
- detail: { command, args },
120
+ detail: { command, args, recovery: true },
121
121
  }));
122
122
  } catch {}
123
123
  })();
@@ -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
  }
@@ -17,6 +17,18 @@ function formatRam(mb: number) {
17
17
  return mb < 1024 ? `${mb.toFixed(0)} MB` : `${(mb / 1024).toFixed(1)} GB`;
18
18
  }
19
19
 
20
+ function formatAge(startedAt?: number) {
21
+ if (!startedAt) return "";
22
+ const secs = Math.round((Date.now() - startedAt) / 1000);
23
+ if (secs < 60) return `${secs}s`;
24
+ const mins = Math.floor(secs / 60);
25
+ if (mins < 60) return `${mins}m`;
26
+ const hrs = Math.floor(mins / 60);
27
+ if (hrs < 24) return `${hrs}h ${mins % 60}m`;
28
+ const days = Math.floor(hrs / 24);
29
+ return `${days}d ${hrs % 24}h`;
30
+ }
31
+
20
32
  export interface GroupRowProps {
21
33
  group: ResourceGroup;
22
34
  Icon: React.ElementType;
@@ -85,13 +97,21 @@ export const GroupRow = memo(function GroupRow({
85
97
  <td className="text-right py-1 px-2 align-top">{formatRam(proc.ramMB)}</td>
86
98
  {!isMobile && (
87
99
  <td className="align-top py-1 px-2">
88
- <button
89
- onClick={(e) => { e.stopPropagation(); onKill(proc.pid); }}
90
- className="opacity-0 group-hover/proc:opacity-100 p-0.5 rounded hover:bg-red-500/20 hover:text-red-500 transition-all"
91
- title={`End process ${proc.pid}`}
92
- >
93
- <X className="size-3" />
94
- </button>
100
+ <div className="flex items-center justify-between gap-1">
101
+ <span
102
+ className="text-text-subtle"
103
+ title={proc.startedAt ? new Date(proc.startedAt).toLocaleString() : ""}
104
+ >
105
+ {formatAge(proc.startedAt)}
106
+ </span>
107
+ <button
108
+ onClick={(e) => { e.stopPropagation(); onKill(proc.pid); }}
109
+ className="opacity-0 group-hover/proc:opacity-100 p-0.5 rounded hover:bg-red-500/20 hover:text-red-500 transition-all"
110
+ title={`End process ${proc.pid}`}
111
+ >
112
+ <X className="size-3" />
113
+ </button>
114
+ </div>
95
115
  </td>
96
116
  )}
97
117
  </tr>
@@ -137,7 +137,7 @@ export const SystemMonitorTab = memo(function SystemMonitorTab() {
137
137
  className="w-20"
138
138
  />
139
139
  {!isMobile && (
140
- <th className="py-1.5 px-2 font-medium w-[130px]">Trend</th>
140
+ <th className="py-1.5 px-2 font-medium w-[130px]">Trend / Age</th>
141
141
  )}
142
142
  </tr>
143
143
  </thead>
@@ -16,6 +16,13 @@ import { toast } from "sonner";
16
16
  */
17
17
  const recentlyClosedViews = new Set<string>();
18
18
 
19
+ /**
20
+ * Track viewTypes whose command dispatch was auto-recovery (not user-initiated).
21
+ * When `webview:create` arrives for a recovery viewType, skip setActiveTab
22
+ * to prevent stealing focus from the user's current tab.
23
+ */
24
+ const recoveryViews = new Set<string>();
25
+
19
26
  /**
20
27
  * Hook that manages the WebSocket connection for extension UI bridge.
21
28
  * Dispatches server messages into the extension Zustand store.
@@ -161,8 +168,11 @@ export function useExtensionWs(enabled = true) {
161
168
  title: msg.title,
162
169
  metadata: { ...existingTab, viewType: viewTypeSlug, panelId: msg.panelId, extensionId: msg.extensionId },
163
170
  });
164
- // Focus the existing tab so Cmd+G / command palette switches to it
165
- useTabStore.getState().setActiveTab(existingTabId);
171
+ // Focus the existing tab only if user explicitly opened it (not auto-recovery)
172
+ if (!recoveryViews.has(viewTypeSlug)) {
173
+ useTabStore.getState().setActiveTab(existingTabId);
174
+ }
175
+ recoveryViews.delete(viewTypeSlug);
166
176
  } else if (!recentlyClosedViews.has(viewTypeSlug)) {
167
177
  // Only create a new tab if this viewType wasn't recently closed by user
168
178
  const currentProject = useTabStore.getState().currentProject;
@@ -223,10 +233,13 @@ export function useExtensionWs(enabled = true) {
223
233
 
224
234
  // Listen for command:execute requests (dispatched by StatusBar / TreeView)
225
235
  const commandHandler = (e: Event) => {
226
- const { command, args } = (e as CustomEvent).detail;
236
+ const { command, args, recovery } = (e as CustomEvent).detail;
227
237
  // User explicitly opened an extension — clear "recently closed" so tab can be created
228
238
  const slug = (command as string).replace(/\.view$/, "");
229
239
  recentlyClosedViews.delete(slug);
240
+ // Track recovery dispatches to avoid stealing focus on webview:create
241
+ if (recovery) recoveryViews.add(slug);
242
+ else recoveryViews.delete(slug);
230
243
  client.send(JSON.stringify({ type: "command:execute", command, args }));
231
244
  };
232
245
  window.addEventListener("ext:command:execute", commandHandler);
@@ -8,7 +8,7 @@ export interface ResourceGroup {
8
8
  label: string;
9
9
  cpu: number;
10
10
  ramMB: number;
11
- processes: { pid: number; cpu: number; ramMB: number; command: string }[];
11
+ processes: { pid: number; cpu: number; ramMB: number; startedAt?: number; command: string }[];
12
12
  }
13
13
 
14
14
  export interface ResourceSnapshot {
@@ -1,4 +0,0 @@
1
- import{o as e}from"./rolldown-runtime-FhOqtrmT.js";import{b as t,x as n}from"./vendor-markdown-0Mxgxy0L.js";import{t as r}from"./text-wrap-AZErifCu.js";import{i,t as a}from"./api-client-DiZgVOok.js";import{n as o}from"./settings-store-BFlBSwKg.js";import"./vendor-mermaid-DU911Xa9.js";import{_ as s,et as c,st as l}from"./index-PZd81rhr.js";import{r as u,t as d}from"./use-monaco-theme-6AirEH08.js";var f=e(n(),1),p=t();function m(e){return{js:`javascript`,jsx:`javascript`,ts:`typescript`,tsx:`typescript`,py:`python`,html:`html`,css:`css`,scss:`scss`,json:`json`,md:`markdown`,mdx:`markdown`,yaml:`yaml`,yml:`yaml`,sh:`shell`,bash:`shell`}[e.split(`.`).pop()?.toLowerCase()??``]??`plaintext`}function h({metadata:e}){let t=e?.filePath,n=e?.projectName,h=e?.ref1,_=e?.ref2,v=e?.file1,y=e?.file2,b=e?.original,x=e?.modified,S=b!=null||x!=null,C=!!(v&&y),[w,T]=(0,f.useState)(null),[E,D]=(0,f.useState)(null),[O,k]=(0,f.useState)(null),[A,j]=(0,f.useState)(!S),[M,N]=(0,f.useState)(null),{wordWrap:P,toggleWordWrap:F}=o(s(e=>({wordWrap:e.wordWrap,toggleWordWrap:e.toggleWordWrap}))),I=d(),L=(0,f.useRef)(null),R=(0,f.useRef)(null),[z,B]=(0,f.useState)(!1),[V,H]=(0,f.useState)();(0,f.useEffect)(()=>{let e=L.current;if(!e)return;let t=new ResizeObserver(([e])=>{e&&H(Math.floor(e.contentRect.height))});return t.observe(e),()=>t.disconnect()},[A,M]),(0,f.useEffect)(()=>{if(S||!n)return;if(j(!0),N(null),k(null),D(null),T(null),v&&y){let e=new URLSearchParams({file1:v,file2:y});a.get(`${i(n)}/files/compare?${e}`).then(e=>{D(e),j(!1)}).catch(e=>{N(e instanceof Error?e.message:`Failed to compare files`),j(!1)});return}if(t){let e=new URLSearchParams({file:t});h&&e.set(`ref`,h),a.get(`${i(n)}/git/file-full-diff?${e}`).then(e=>{k(e),j(!1)}).catch(e=>{N(e instanceof Error?e.message:`Failed to load diff`),j(!1)});return}let e;if(h||_){let t=new URLSearchParams;h&&t.set(`ref1`,h),_&&t.set(`ref2`,_),e=`${i(n)}/git/diff?${t}`}else e=`${i(n)}/git/diff`;a.get(e).then(e=>{T(e.diff),j(!1)}).catch(e=>{N(e instanceof Error?e.message:`Failed to load diff`),j(!1)})},[t,n,h,_,v,y,S]);let{original:U,modified:W}=(0,f.useMemo)(()=>S?{original:b??``,modified:x??``}:C&&E?E:O||(w?g(w):{original:``,modified:``}),[w,S,b,x,C,E,O]),G=(0,f.useMemo)(()=>{let e=t??y??v;return e?m(e):`plaintext`},[t,v,y]),K=typeof window<`u`&&window.innerWidth<768,q=!K;return(0,f.useEffect)(()=>{let e=R.current;if(!e)return;let t=K||P?`on`:`off`;e.updateOptions({diffWordWrap:t}),e.getOriginalEditor().updateOptions({wordWrapOverride2:t}),e.getModifiedEditor().updateOptions({wordWrapOverride2:t})},[P,K,z]),!n&&!S?(0,p.jsx)(`div`,{className:`flex items-center justify-center h-full text-muted-foreground text-sm`,children:`No project selected.`}):A?(0,p.jsxs)(`div`,{className:`flex items-center justify-center h-full gap-2 text-muted-foreground`,children:[(0,p.jsx)(c,{className:`size-5 animate-spin`}),(0,p.jsx)(`span`,{className:`text-sm`,children:`Loading diff...`})]}):M?(0,p.jsx)(`div`,{className:`flex items-center justify-center h-full text-destructive text-sm`,children:M}):!S&&!C&&!O&&!U&&!W?(0,p.jsxs)(`div`,{className:`flex flex-col items-center justify-center h-full gap-2 text-muted-foreground`,children:[(0,p.jsx)(l,{className:`size-8`}),(0,p.jsx)(`p`,{className:`text-sm`,children:`No content changes`}),t&&(0,p.jsx)(`p`,{className:`text-xs font-mono`,children:t})]}):(0,p.jsxs)(`div`,{className:`flex flex-col h-full`,children:[!K&&(0,p.jsx)(`div`,{className:`flex items-center justify-end gap-0.5 px-2 py-0.5 border-b border-border shrink-0`,children:(0,p.jsx)(`button`,{type:`button`,onClick:F,title:`Toggle word wrap`,className:`p-1 rounded hover:bg-muted transition-colors ${P?`bg-muted text-foreground`:``}`,children:(0,p.jsx)(r,{className:`size-3.5`})})}),(0,p.jsx)(`div`,{ref:L,className:`flex-1 overflow-hidden`,children:V&&V>0?(0,p.jsx)(u,{height:V,language:G,original:U,modified:W,theme:I,onMount:e=>{R.current=e,B(!0)},options:{fontSize:K?11:13,fontFamily:`Menlo, Monaco, Consolas, monospace`,diffWordWrap:K||P?`on`:`off`,renderSideBySide:q,useInlineViewWhenSpaceIsLimited:!1,readOnly:!0,automaticLayout:!0,scrollBeyondLastLine:!1},loading:(0,p.jsx)(c,{className:`size-5 animate-spin text-muted-foreground`})}):(0,p.jsx)(`div`,{className:`flex items-center justify-center h-full`,children:(0,p.jsx)(c,{className:`size-5 animate-spin text-muted-foreground`})})})]})}function g(e){let t=e.split(`
2
- `),n=[],r=[],i=!1;for(let e of t)if(!(e.startsWith(`diff --git`)||e.startsWith(`diff --no-index`)||e.startsWith(`index `)||e.startsWith(`new file`)||e.startsWith(`deleted file`)||e.startsWith(`old mode`)||e.startsWith(`new mode`)||e.startsWith(`---`)||e.startsWith(`+++`)||e.startsWith(`Binary files`)||e.startsWith(`\\ No newline`))){if(e.startsWith(`@@`)){i=!0;continue}if(i)if(e.startsWith(`-`))n.push(e.slice(1));else if(e.startsWith(`+`))r.push(e.slice(1));else{let t=e.startsWith(` `)?e.slice(1):e;n.push(t),r.push(t)}}return{original:n.join(`
3
- `),modified:r.join(`
4
- `)}}export{h as DiffViewer};
@@ -1,3 +0,0 @@
1
- import{o as e}from"./rolldown-runtime-FhOqtrmT.js";import{b as t,x as n}from"./vendor-markdown-0Mxgxy0L.js";import{r}from"./api-client-DiZgVOok.js";import{et as i,x as a}from"./index-PZd81rhr.js";var o=e(n(),1),s=t(),c=`<script>
2
- function acquireVsCodeApi(){return{postMessage:function(m){window.parent.postMessage(m,"*")},getState:function(){try{return JSON.parse(sessionStorage.getItem("vscode-state")||"null")}catch{return null}},setState:function(s){sessionStorage.setItem("vscode-state",JSON.stringify(s));return s}}}
3
- <\/script>`;function l(e){if(!e)return e;let t=e.indexOf(`<head>`);return t===-1?c+e:e.slice(0,t+6)+c+e.slice(t+6)}function u({metadata:e}){let t=e?.panelId,n=e?.viewType,c=e?.projectName||void 0,[u,d]=(0,o.useState)(!1),f=a(e=>e.contributions!==null),p=a(e=>{if(t&&e.webviewPanels[t])return e.webviewPanels[t];if(n){let t=n.includes(`.`)?n:`${n}.view`;return Object.values(e.webviewPanels).find(e=>e.viewType===n||e.viewType===t)}}),m=p?.id??t,h=(0,o.useRef)(null),g=p?.html??``,_=l(g),v=(0,o.useRef)(null);(0,o.useEffect)(()=>{if(p||!n||!f||c&&c===v.current)return;c&&(v.current=c);let e=n.includes(`.`)?n:`${n}.view`,t=!1;async function i(){let n=[];if(c)try{let e=r(),t=(await(await fetch(`/api/projects`,e?{headers:{Authorization:`Bearer ${e}`}}:{})).json()).data?.find(e=>e.name===c);t&&(n=[t.path])}catch{}t||window.dispatchEvent(new CustomEvent(`ext:command:execute`,{detail:{command:e,args:n}}))}return i(),()=>{t=!0}},[p,n,c,f]);let y=e?.extensionId,b=a(e=>{if(y&&e.activationErrors[y])return e.activationErrors[y];if(n){for(let[t,r]of Object.entries(e.activationErrors))if(t===`ext-${n}`)return r}}),x=(0,o.useCallback)(()=>{if(d(!1),!n)return;let e=n.includes(`.`)?n:`${n}.view`;(async()=>{try{let t=r(),n=(await(await fetch(`/api/projects`,t?{headers:{Authorization:`Bearer ${t}`}}:{})).json()).data?.find(e=>e.name===c),i=n?[n.path]:[];window.dispatchEvent(new CustomEvent(`ext:command:execute`,{detail:{command:e,args:i}}))}catch{}})()},[n,c]),S=(0,o.useRef)(null),C=(0,o.useRef)(n);return(0,o.useEffect)(()=>{S.current=m??null},[m]),(0,o.useEffect)(()=>{C.current=n},[n]),(0,o.useEffect)(()=>()=>{let e=S.current;if(e){let t=C.current;a.getState().removeWebviewPanel(e),window.dispatchEvent(new CustomEvent(`ext:webview:close`,{detail:{panelId:e,viewType:t}}))}},[]),(0,o.useEffect)(()=>{if(p){d(!1);return}if(!f||!n)return;let e=0,t=setInterval(()=>{if(e++,e>3){clearInterval(t),d(!0);return}x()},2e3);return()=>clearInterval(t)},[p,f,n,x]),(0,o.useEffect)(()=>{if(!m)return;let e=e=>{h.current&&e.source===h.current.contentWindow&&window.dispatchEvent(new CustomEvent(`ext:webview:send`,{detail:{panelId:m,message:e.data}}))};return window.addEventListener(`message`,e),()=>window.removeEventListener(`message`,e)},[m]),(0,o.useEffect)(()=>{if(!m)return;let e=e=>{let{panelId:t,message:n}=e.detail;t===m&&h.current?.contentWindow?.postMessage(n,`*`)};return window.addEventListener(`ext:webview:message`,e),()=>window.removeEventListener(`ext:webview:message`,e)},[m]),!p||!g?(0,s.jsx)(`div`,{className:`flex flex-col items-center justify-center h-full gap-3 text-sm text-text-subtle`,children:u?(0,s.jsxs)(s.Fragment,{children:[(0,s.jsx)(`span`,{className:`text-destructive font-medium`,children:`Extension failed to load`}),b&&(0,s.jsx)(`span`,{className:`text-xs text-muted-foreground max-w-md text-center`,children:b}),(0,s.jsx)(`button`,{onClick:x,className:`text-xs text-primary hover:underline`,children:`Retry`})]}):(0,s.jsxs)(s.Fragment,{children:[(0,s.jsx)(i,{className:`size-5 animate-spin`}),(0,s.jsx)(`span`,{children:`Loading extension...`})]})}):(0,s.jsx)(`div`,{className:`h-full w-full relative`,children:(0,s.jsx)(`iframe`,{ref:h,srcDoc:_,sandbox:`allow-scripts`,className:`w-full h-full border-0 bg-white dark:bg-zinc-900`,title:p.title},m)})}export{u as ExtensionWebview};