@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.
- package/CHANGELOG.md +14 -0
- package/assets/skills/ppm/SKILL.md +1 -1
- package/assets/skills/ppm/references/http-api.md +3 -1
- package/dist/web/assets/{audio-preview-B8XiU4Bw.js → audio-preview-DLz0dEw6.js} +1 -1
- package/dist/web/assets/{chat-tab-B1m7T_2n.js → chat-tab-B0L_i2ls.js} +3 -3
- package/dist/web/assets/{code-editor-CQSDgP7X.js → code-editor-BEdecTYr.js} +2 -2
- package/dist/web/assets/{conflict-editor-BPjmtXlC.js → conflict-editor-CkUhGJsN.js} +1 -1
- package/dist/web/assets/{database-viewer-Cl31pR9W.js → database-viewer-CRBrhKbW.js} +1 -1
- package/dist/web/assets/diff-viewer-DcyIipTd.js +4 -0
- package/dist/web/assets/{docx-preview-D_P_e_0O.js → docx-preview-m0bGH6IL.js} +1 -1
- package/dist/web/assets/extension-webview-D50XqcZE.js +3 -0
- package/dist/web/assets/{git-log-panel-CAa4j8NA.js → git-log-panel-CyYHsqIb.js} +1 -1
- package/dist/web/assets/{glide-data-grid-DbtdLkFk.js → glide-data-grid-Dn8CvXh7.js} +1 -1
- package/dist/web/assets/{image-preview-DjWCljN-.js → image-preview-DTfGdGj8.js} +1 -1
- package/dist/web/assets/index-BgHCBGwE.js +27 -0
- package/dist/web/assets/keybindings-store-BhM4ou6C.js +1 -0
- package/dist/web/assets/{markdown-renderer-BojoStRy.js → markdown-renderer-940Y1Maq.js} +1 -1
- package/dist/web/assets/notification-store-DWsPQ2XR.js +1 -0
- package/dist/web/assets/{pdf-preview-19LY16zS.js → pdf-preview-D8lVKDTs.js} +1 -1
- package/dist/web/assets/{port-forwarding-tab-DIqVwGrL.js → port-forwarding-tab-B-hu3lGI.js} +1 -1
- package/dist/web/assets/{postgres-viewer-DOTykgcg.js → postgres-viewer-DfCzELiK.js} +1 -1
- package/dist/web/assets/{settings-tab-C5S_iYSH.js → settings-tab-BNGqONuA.js} +1 -1
- package/dist/web/assets/{sql-query-editor-BsxW0lTw.js → sql-query-editor-D3Jyjw6V.js} +1 -1
- package/dist/web/assets/{sqlite-viewer-Fq4NnQg6.js → sqlite-viewer-B5Mrzox5.js} +1 -1
- package/dist/web/assets/system-monitor-tab-_ZjrnvgM.js +1 -0
- package/dist/web/assets/{terminal-tab-Lu2U4vpg.js → terminal-tab-DNswsdLl.js} +1 -1
- package/dist/web/assets/{video-preview-8Vrdwy25.js → video-preview-CaB29_z0.js} +1 -1
- package/dist/web/index.html +1 -1
- package/dist/web/sw.js +1 -1
- package/package.json +1 -1
- package/src/server/routes/git.ts +2 -1
- package/src/server/routes/projects.ts +37 -0
- package/src/services/git.service.ts +32 -7
- package/src/services/resource-monitor-utils.ts +8 -5
- package/src/services/resource-monitor.service.ts +2 -1
- package/src/web/components/editor/diff-viewer.tsx +1 -0
- package/src/web/components/extensions/extension-webview.tsx +2 -2
- package/src/web/components/layout/add-project-form.tsx +213 -70
- package/src/web/components/system/system-monitor-group-row.tsx +27 -7
- package/src/web/components/system/system-monitor-tab.tsx +1 -1
- package/src/web/hooks/use-extension-ws.ts +16 -3
- package/src/web/hooks/use-resource-monitor.ts +1 -1
- package/dist/web/assets/diff-viewer-sbO35hMr.js +0 -4
- package/dist/web/assets/extension-webview-B2Q7T_NQ.js +0 -3
- package/dist/web/assets/index-PZd81rhr.js +0 -27
- package/dist/web/assets/keybindings-store-DZjJtyij.js +0 -1
- package/dist/web/assets/notification-store-CgsqI4c0.js +0 -1
- 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
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
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 <
|
|
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
|
|
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
|
-
//
|
|
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
|
-
<
|
|
85
|
-
{/*
|
|
86
|
-
<div
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
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={
|
|
94
|
-
onChange={(e) => {
|
|
95
|
-
|
|
96
|
-
|
|
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
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
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
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
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
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
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
|
-
<
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
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
|
|
165
|
-
|
|
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};
|