@hienlh/ppm 0.13.53 → 0.13.55
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 +22 -0
- package/assets/skills/ppm/SKILL.md +2 -2
- package/assets/skills/ppm/references/cli-reference.md +11 -0
- package/assets/skills/ppm/references/http-api.md +7 -1
- package/dist/web/assets/ai-settings-section-AxLbNnLW.js +1 -0
- package/dist/web/assets/{api-settings-C3T95dWg.js → api-settings-BJTjIG4U.js} +1 -1
- package/dist/web/assets/architecture-PBZL5I3N-CkdUQjA_.js +1 -0
- package/dist/web/assets/arrow-down-D825m4vm.js +1 -0
- package/dist/web/assets/{audio-preview-DzlMrjXC.js → audio-preview-B53oeW0y.js} +1 -1
- package/dist/web/assets/chat-tab-Clzw7eDP.js +16 -0
- package/dist/web/assets/chevron-down-BMo4cBth.js +1 -0
- package/dist/web/assets/code-editor-BgU5lFVC.js +8 -0
- package/dist/web/assets/{conflict-editor-DsL9J6Ao.js → conflict-editor-C3l3pgtV.js} +3 -3
- package/dist/web/assets/csv-parser-D1b_lg2T.js +6 -0
- package/dist/web/assets/{csv-preview-B3Dyhgho.js → csv-preview-asMfgR0r.js} +2 -2
- package/dist/web/assets/{data-grid-overlay-editor-C1UUm7Ob.js → data-grid-overlay-editor-DGjqvYn6.js} +1 -1
- package/dist/web/assets/database-viewer-DNYgu_Jv.js +1 -0
- package/dist/web/assets/diff-viewer-Dec4mKgl.js +4 -0
- package/dist/web/assets/{esm-DCbn6xno.js → esm-xVTUq__o.js} +1 -1
- package/dist/web/assets/{extension-webview-SqqLBksj.js → extension-webview-b7T0yAq2.js} +1 -1
- package/dist/web/assets/eye-off-BacF7RVS.js +1 -0
- package/dist/web/assets/git-log-panel-CWTTJERX.js +1 -0
- package/dist/web/assets/gitGraph-HDMCJU4V-D3UR56AG.js +1 -0
- package/dist/web/assets/glide-data-grid-_9gGGfZy.js +136 -0
- package/dist/web/assets/{image-preview-CVERB6Hn.js → image-preview-CzXKlWft.js} +1 -1
- package/dist/web/assets/index-1_isAfRS.js +27 -0
- package/dist/web/assets/index-CDPVPZHJ.css +2 -0
- package/dist/web/assets/info-3K5VOQVL-DUhLSKI2.js +1 -0
- package/dist/web/assets/{input-_LFQwhzd.js → input-By_lZeCs.js} +1 -1
- package/dist/web/assets/keybindings-store-BicDU0b1.js +1 -0
- package/dist/web/assets/{markdown-renderer-F5aFyJ-g.js → markdown-renderer-DWIBF9Jg.js} +3 -3
- package/dist/web/assets/notification-store-D_2wCv0z.js +1 -0
- package/dist/web/assets/{number-overlay-editor-CyEqxXcg.js → number-overlay-editor-DtUBprPW.js} +1 -1
- package/dist/web/assets/packet-RMMSAZCW-BIpeVUGW.js +1 -0
- package/dist/web/assets/panel-store-C9VAhbZz.js +1 -0
- package/dist/web/assets/{pdf-preview-RgwYR2Lj.js → pdf-preview-C51mDesS.js} +1 -1
- package/dist/web/assets/pie-UPGHQEXC-CNoizzjb.js +1 -0
- package/dist/web/assets/port-forwarding-tab-BmRMiN32.js +1 -0
- package/dist/web/assets/{postgres-viewer-Dq2nI9jE.js → postgres-viewer-DuiEoUGK.js} +3 -3
- package/dist/web/assets/project-store-DlbHpIq0.js +1 -0
- package/dist/web/assets/radar-KQ55EAFF-7dns-ho5.js +1 -0
- package/dist/web/assets/{settings-store-8FpQDjEA.js → settings-store-DQUFTPk2.js} +2 -2
- package/dist/web/assets/settings-tab-p3lxp6_T.js +1 -0
- package/dist/web/assets/{sql-query-editor-CL6O_4eW.js → sql-query-editor-BA80nuKp.js} +1 -1
- package/dist/web/assets/{sqlite-viewer-CcJz9Bva.js → sqlite-viewer-Bev2XJe7.js} +1 -1
- package/dist/web/assets/system-monitor-tab-DfpsOgL3.js +1 -0
- package/dist/web/assets/{tab-store-CNas5Ny8.js → tab-store-CIcbSn0c.js} +1 -1
- package/dist/web/assets/{terminal-tab-BlyuDIu5.js → terminal-tab-Ca5kyUS7.js} +2 -2
- package/dist/web/assets/treemap-KZPCXAKY-D3DZCLoE.js +1 -0
- package/dist/web/assets/{use-blob-url-DB4nNruT.js → use-blob-url-VgTGpely.js} +1 -1
- package/dist/web/assets/{use-monaco-theme-DEI-tJAh.js → use-monaco-theme-BLIgarH5.js} +1 -1
- package/dist/web/assets/{vendor-mermaid-D2KKkqNs.js → vendor-mermaid-DkqjpqJK.js} +2 -2
- package/dist/web/assets/{video-preview-C8s0VXIf.js → video-preview-Bvd0OaYA.js} +1 -1
- package/dist/web/assets/wifi-LJEyIdXf.js +1 -0
- package/dist/web/index.html +21 -20
- package/dist/web/sw.js +1 -1
- package/docs/project-changelog.md +38 -1
- package/docs/system-architecture.md +2 -1
- package/package.json +1 -1
- package/packages/ext-git-graph/src/webview-html.ts +29 -8
- package/src/cli/commands/db-cmd.ts +30 -4
- package/src/server/index.ts +4 -0
- package/src/server/middleware/auth.ts +9 -0
- package/src/server/routes/resources.ts +61 -0
- package/src/services/ppmbot/cli-reference-default.ts +6 -1
- package/src/services/resource-monitor-utils.ts +129 -0
- package/src/services/resource-monitor.service.ts +122 -0
- package/src/web/components/editor/editor-breadcrumb.tsx +8 -1
- package/src/web/components/git/git-log-panel.tsx +206 -0
- package/src/web/components/git/git-ref-badge.tsx +150 -0
- package/src/web/components/git/git-status-panel.tsx +18 -0
- package/src/web/components/layout/mobile-nav.tsx +2 -0
- package/src/web/components/layout/sidebar.tsx +6 -0
- package/src/web/components/layout/tab-bar.tsx +3 -0
- package/src/web/components/layout/tab-content.tsx +10 -0
- package/src/web/components/layout/tab-pool.tsx +2 -0
- package/src/web/components/system/resource-status-bar.tsx +66 -0
- package/src/web/components/system/sparkline-canvas.tsx +64 -0
- package/src/web/components/system/system-monitor-tab.tsx +194 -0
- package/src/web/hooks/use-resource-monitor.ts +114 -0
- package/src/web/stores/panel-store.ts +1 -1
- package/src/web/stores/tab-store.ts +3 -1
- package/templates/skill/SKILL.md.tmpl +1 -1
- package/dist/web/assets/ai-settings-section-AuV6Lzz2.js +0 -1
- package/dist/web/assets/architecture-PBZL5I3N-fAJMexNi.js +0 -1
- package/dist/web/assets/chat-tab-BpP-9jSF.js +0 -16
- package/dist/web/assets/code-editor-DUrnjDXe.js +0 -8
- package/dist/web/assets/csv-parser-Dly5nqE1.js +0 -6
- package/dist/web/assets/database-viewer-BzZ_B08X.js +0 -1
- package/dist/web/assets/diff-viewer-BTtOJWuk.js +0 -4
- package/dist/web/assets/file-store-DOxcU_7s.js +0 -1
- package/dist/web/assets/gitGraph-HDMCJU4V-ClRAoBAn.js +0 -1
- package/dist/web/assets/glide-data-grid-YCbGSPc8.js +0 -136
- package/dist/web/assets/index-CKKoR3gY.css +0 -2
- package/dist/web/assets/index-D3gMHLKc.js +0 -27
- package/dist/web/assets/info-3K5VOQVL-D8uyBfC_.js +0 -1
- package/dist/web/assets/keybindings-store-BY4JJMPB.js +0 -1
- package/dist/web/assets/notification-store-CmFhp1D7.js +0 -1
- package/dist/web/assets/packet-RMMSAZCW-BJj3FH_w.js +0 -1
- package/dist/web/assets/panel-store-C8wwxBpn.js +0 -1
- package/dist/web/assets/pie-UPGHQEXC-BKfHRBz7.js +0 -1
- package/dist/web/assets/port-forwarding-tab-CRBTvvYM.js +0 -1
- package/dist/web/assets/radar-KQ55EAFF-DkybKTt9.js +0 -1
- package/dist/web/assets/scroll-area-BDi_FNzr.js +0 -1
- package/dist/web/assets/settings-tab-BrtuPA9W.js +0 -1
- package/dist/web/assets/treemap-KZPCXAKY-BvDgIWW9.js +0 -1
- /package/dist/web/assets/{api-client-DIhJ5qVW.js → api-client-BK4NPNoY.js} +0 -0
- /package/dist/web/assets/{chevron-right-DnHIvvcy.js → chevron-right-CD8e6Aj4.js} +0 -0
- /package/dist/web/assets/{code-DGBecc50.js → code-DiNmA3eR.js} +0 -0
- /package/dist/web/assets/{data-grid-types-D2cHE8hx.js → data-grid-types-DzL5W2em.js} +0 -0
- /package/dist/web/assets/{database-DOWH9-Vv.js → database-Dc8mr-dP.js} +0 -0
- /package/dist/web/assets/{dist-BoIkGNC8.js → dist-BM2EHhLH.js} +0 -0
- /package/dist/web/assets/{dist-DVk8T0R5.js → dist-CohudVKa.js} +0 -0
- /package/dist/web/assets/{file-exclamation-point-BwzaQ50n.js → file-exclamation-point-B__2Hrd6.js} +0 -0
- /package/dist/web/assets/{globe-B4Ilypbs.js → globe-CQ8NAYvi.js} +0 -0
- /package/dist/web/assets/{katex-C10ndCVt.js → katex-CHaeM9QC.js} +0 -0
- /package/dist/web/assets/{lib-C2D8j3K3.js → lib-LPmTkMu4.js} +0 -0
- /package/dist/web/assets/{react-DMIOAtcX.js → react-DHBl6KRc.js} +0 -0
- /package/dist/web/assets/{refresh-cw-BjrAbUJe.js → refresh-cw-CRD2qr4U.js} +0 -0
- /package/dist/web/assets/{search-tM8K5zWU.js → search-D90WJ5fo.js} +0 -0
- /package/dist/web/assets/{sparkles-CulWHe4c.js → sparkles-KCOEy7QI.js} +0 -0
- /package/dist/web/assets/{table-BzjWcs87.js → table-2wDtM4_B.js} +0 -0
- /package/dist/web/assets/{text-wrap-DJz9Bgpa.js → text-wrap-AZErifCu.js} +0 -0
- /package/dist/web/assets/{utils-CQux7CsO.js → utils-CSCvNZxE.js} +0 -0
- /package/dist/web/assets/{vendor-xterm-D1P36hcr.js → vendor-xterm-vh96p1Au.js} +0 -0
- /package/dist/web/assets/{x-BPReZWnP.js → x-ClICkcxR.js} +0 -0
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure utility functions for resource monitoring:
|
|
3
|
+
* ps output parsing, process tree building, and categorization.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import type { ProcessEntry, ResourceGroup } from "./resource-monitor.service.ts";
|
|
7
|
+
|
|
8
|
+
// ── Categorization patterns ────────────────────────────────────────────
|
|
9
|
+
|
|
10
|
+
const CATEGORY_PATTERNS: [ResourceGroup["type"], RegExp][] = [
|
|
11
|
+
["terminal", /^(bash|zsh|sh|fish|csh|tcsh|dash|ksh|pwsh|powershell)$/i],
|
|
12
|
+
["ai-tool", /^(claude|anthropic)/i],
|
|
13
|
+
["build", /^(node|bun|tsc|vite|webpack|esbuild|turbo|rollup|swc)$/i],
|
|
14
|
+
];
|
|
15
|
+
|
|
16
|
+
function categorize(cmd: string): ResourceGroup["type"] {
|
|
17
|
+
const basename = cmd.split("/").pop()?.split(" ")[0] ?? cmd;
|
|
18
|
+
for (const [type, re] of CATEGORY_PATTERNS) {
|
|
19
|
+
if (re.test(basename)) return type;
|
|
20
|
+
}
|
|
21
|
+
return "unknown";
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// ── Parser ─────────────────────────────────────────────────────────────
|
|
25
|
+
|
|
26
|
+
/** Parse `ps -e -o pid,ppid,%cpu,rss,args` output into structured entries */
|
|
27
|
+
export function parseProcessList(stdout: string): ProcessEntry[] {
|
|
28
|
+
const lines = stdout.trim().split("\n");
|
|
29
|
+
if (lines.length < 2) return [];
|
|
30
|
+
|
|
31
|
+
const entries: ProcessEntry[] = [];
|
|
32
|
+
for (let i = 1; i < lines.length; i++) {
|
|
33
|
+
const line = lines[i]?.trim();
|
|
34
|
+
if (!line) continue;
|
|
35
|
+
const parts = line.split(/\s+/);
|
|
36
|
+
if (parts.length < 5) continue;
|
|
37
|
+
|
|
38
|
+
const pid = parseInt(parts[0]!, 10);
|
|
39
|
+
const ppid = parseInt(parts[1]!, 10);
|
|
40
|
+
const cpu = parseFloat(parts[2]!);
|
|
41
|
+
const rssKB = parseInt(parts[3]!, 10);
|
|
42
|
+
const command = parts.slice(4).join(" ");
|
|
43
|
+
|
|
44
|
+
if (isNaN(pid) || pid === 0 || !command) continue;
|
|
45
|
+
|
|
46
|
+
entries.push({
|
|
47
|
+
pid,
|
|
48
|
+
ppid,
|
|
49
|
+
cpu: Math.round(cpu * 10) / 10,
|
|
50
|
+
ramMB: Math.round((rssKB / 1024) * 10) / 10,
|
|
51
|
+
command,
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
return entries;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// ── Tree builder ───────────────────────────────────────────────────────
|
|
58
|
+
|
|
59
|
+
/** Build flat list of all descendants of rootPid via ppid traversal */
|
|
60
|
+
export function buildTree(entries: ProcessEntry[], rootPid: number): ProcessEntry[] {
|
|
61
|
+
const childMap = new Map<number, ProcessEntry[]>();
|
|
62
|
+
for (const e of entries) {
|
|
63
|
+
const list = childMap.get(e.ppid) ?? [];
|
|
64
|
+
list.push(e);
|
|
65
|
+
childMap.set(e.ppid, list);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const result: ProcessEntry[] = [];
|
|
69
|
+
const queue = [rootPid];
|
|
70
|
+
while (queue.length > 0) {
|
|
71
|
+
const pid = queue.shift()!;
|
|
72
|
+
const children = childMap.get(pid) ?? [];
|
|
73
|
+
for (const child of children) {
|
|
74
|
+
result.push(child);
|
|
75
|
+
queue.push(child.pid);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
return result;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// ── Grouping ───────────────────────────────────────────────────────────
|
|
82
|
+
|
|
83
|
+
const TYPE_LABELS: Record<ResourceGroup["type"], string> = {
|
|
84
|
+
server: "PPM Server",
|
|
85
|
+
terminal: "Terminals",
|
|
86
|
+
"ai-tool": "AI Tools",
|
|
87
|
+
build: "Build Tools",
|
|
88
|
+
unknown: "Other",
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
/** Group processes into resource groups by category */
|
|
92
|
+
export function groupProcesses(
|
|
93
|
+
serverEntry: ProcessEntry | undefined,
|
|
94
|
+
children: ProcessEntry[],
|
|
95
|
+
): ResourceGroup[] {
|
|
96
|
+
const groups: ResourceGroup[] = [];
|
|
97
|
+
|
|
98
|
+
if (serverEntry) {
|
|
99
|
+
groups.push({
|
|
100
|
+
type: "server",
|
|
101
|
+
label: "PPM Server",
|
|
102
|
+
cpu: serverEntry.cpu,
|
|
103
|
+
ramMB: serverEntry.ramMB,
|
|
104
|
+
processes: [{ pid: serverEntry.pid, cpu: serverEntry.cpu, ramMB: serverEntry.ramMB, command: serverEntry.command }],
|
|
105
|
+
});
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const buckets = new Map<ResourceGroup["type"], ProcessEntry[]>();
|
|
109
|
+
for (const child of children) {
|
|
110
|
+
const type = categorize(child.command);
|
|
111
|
+
const list = buckets.get(type) ?? [];
|
|
112
|
+
list.push(child);
|
|
113
|
+
buckets.set(type, list);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
for (const type of ["terminal", "ai-tool", "build", "unknown"] as const) {
|
|
117
|
+
const procs = buckets.get(type);
|
|
118
|
+
if (!procs?.length) continue;
|
|
119
|
+
groups.push({
|
|
120
|
+
type,
|
|
121
|
+
label: TYPE_LABELS[type],
|
|
122
|
+
cpu: Math.round(procs.reduce((s, p) => s + p.cpu, 0) * 10) / 10,
|
|
123
|
+
ramMB: Math.round(procs.reduce((s, p) => s + p.ramMB, 0) * 10) / 10,
|
|
124
|
+
processes: procs.map((p) => ({ pid: p.pid, cpu: p.cpu, ramMB: p.ramMB, command: p.command })),
|
|
125
|
+
});
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
return groups;
|
|
129
|
+
}
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Resource Monitor Service — polls `ps` to track PPM process tree,
|
|
3
|
+
* categorizes processes, and maintains a ring buffer of snapshots.
|
|
4
|
+
* Lazy polling: only active when SSE subscribers exist.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { parseProcessList, buildTree, groupProcesses } from "./resource-monitor-utils.ts";
|
|
8
|
+
|
|
9
|
+
// ── Types ──────────────────────────────────────────────────────────────
|
|
10
|
+
|
|
11
|
+
export interface ProcessEntry {
|
|
12
|
+
pid: number;
|
|
13
|
+
ppid: number;
|
|
14
|
+
cpu: number;
|
|
15
|
+
ramMB: number;
|
|
16
|
+
command: string;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface ResourceGroup {
|
|
20
|
+
type: "server" | "terminal" | "ai-tool" | "build" | "unknown";
|
|
21
|
+
label: string;
|
|
22
|
+
cpu: number;
|
|
23
|
+
ramMB: number;
|
|
24
|
+
processes: Omit<ProcessEntry, "ppid">[];
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface ResourceSnapshot {
|
|
28
|
+
timestamp: number;
|
|
29
|
+
server: { pid: number; cpu: number; ramMB: number };
|
|
30
|
+
total: { cpu: number; ramMB: number; processCount: number };
|
|
31
|
+
groups: ResourceGroup[];
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
type SnapshotCallback = (snapshot: ResourceSnapshot) => void;
|
|
35
|
+
|
|
36
|
+
// ── Service ────────────────────────────────────────────────────────────
|
|
37
|
+
|
|
38
|
+
const RING_BUFFER_MAX = 600; // 30 min at 3s interval
|
|
39
|
+
const POLL_INTERVAL = 3000;
|
|
40
|
+
|
|
41
|
+
class ResourceMonitorService {
|
|
42
|
+
private ringBuffer: ResourceSnapshot[] = [];
|
|
43
|
+
private subscribers = new Set<SnapshotCallback>();
|
|
44
|
+
private timer: ReturnType<typeof setInterval> | null = null;
|
|
45
|
+
|
|
46
|
+
subscribe(cb: SnapshotCallback) {
|
|
47
|
+
this.subscribers.add(cb);
|
|
48
|
+
if (this.subscribers.size === 1) this.startPolling();
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
unsubscribe(cb: SnapshotCallback) {
|
|
52
|
+
this.subscribers.delete(cb);
|
|
53
|
+
if (this.subscribers.size === 0) this.stopPolling();
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
getLatest(): ResourceSnapshot | null {
|
|
57
|
+
return this.ringBuffer.at(-1) ?? null;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
getHistory(): ResourceSnapshot[] {
|
|
61
|
+
return this.ringBuffer;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
private startPolling() {
|
|
65
|
+
if (this.timer) return;
|
|
66
|
+
if (process.platform === "win32") return; // ps not available on Windows
|
|
67
|
+
this.poll(); // immediate first poll
|
|
68
|
+
this.timer = setInterval(() => this.poll(), POLL_INTERVAL);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
private stopPolling() {
|
|
72
|
+
if (this.timer) {
|
|
73
|
+
clearInterval(this.timer);
|
|
74
|
+
this.timer = null;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
private async poll() {
|
|
79
|
+
try {
|
|
80
|
+
const proc = Bun.spawn({
|
|
81
|
+
cmd: ["ps", "-e", "-o", "pid,ppid,%cpu,rss,args"],
|
|
82
|
+
stdout: "pipe",
|
|
83
|
+
stderr: "ignore",
|
|
84
|
+
});
|
|
85
|
+
const stdout = await new Response(proc.stdout).text();
|
|
86
|
+
await proc.exited;
|
|
87
|
+
|
|
88
|
+
const entries = parseProcessList(stdout);
|
|
89
|
+
const rootPid = process.pid;
|
|
90
|
+
const serverEntry = entries.find((e) => e.pid === rootPid);
|
|
91
|
+
const children = buildTree(entries, rootPid);
|
|
92
|
+
const groups = groupProcesses(serverEntry, children);
|
|
93
|
+
|
|
94
|
+
const allProcs = serverEntry ? [serverEntry, ...children] : children;
|
|
95
|
+
const snapshot: ResourceSnapshot = {
|
|
96
|
+
timestamp: Date.now(),
|
|
97
|
+
server: serverEntry
|
|
98
|
+
? { pid: serverEntry.pid, cpu: serverEntry.cpu, ramMB: serverEntry.ramMB }
|
|
99
|
+
: { pid: rootPid, cpu: 0, ramMB: 0 },
|
|
100
|
+
total: {
|
|
101
|
+
cpu: Math.round(allProcs.reduce((s, p) => s + p.cpu, 0) * 10) / 10,
|
|
102
|
+
ramMB: Math.round(allProcs.reduce((s, p) => s + p.ramMB, 0) * 10) / 10,
|
|
103
|
+
processCount: allProcs.length,
|
|
104
|
+
},
|
|
105
|
+
groups,
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
this.ringBuffer.push(snapshot);
|
|
109
|
+
if (this.ringBuffer.length > RING_BUFFER_MAX) {
|
|
110
|
+
this.ringBuffer.shift();
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
for (const cb of this.subscribers) {
|
|
114
|
+
try { cb(snapshot); } catch {}
|
|
115
|
+
}
|
|
116
|
+
} catch (err) {
|
|
117
|
+
console.error("[ResourceMonitor] poll error:", err);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
export const resourceMonitor = new ResourceMonitorService();
|
|
@@ -165,7 +165,14 @@ function SegmentDropdown({ segment, isLast, projectName, onFileClick }: SegmentD
|
|
|
165
165
|
|
|
166
166
|
function handleOpenChange(open: boolean) {
|
|
167
167
|
if (open && !isLoaded) {
|
|
168
|
-
|
|
168
|
+
// Load ancestor directories top-down so mergeChildren can traverse
|
|
169
|
+
// the tree to the target (needed when file opened via search/quick-open)
|
|
170
|
+
const parts = segment.parentPath.split("/").filter(Boolean);
|
|
171
|
+
(async () => {
|
|
172
|
+
for (let i = 0; i <= parts.length; i++) {
|
|
173
|
+
await loadChildren(projectName, parts.slice(0, i).join("/"));
|
|
174
|
+
}
|
|
175
|
+
})();
|
|
169
176
|
}
|
|
170
177
|
}
|
|
171
178
|
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
import { useEffect, useState, useCallback, useRef } from "react";
|
|
2
|
+
import { Loader2, RefreshCw, GitCommitHorizontal } from "lucide-react";
|
|
3
|
+
import { api, projectUrl } from "@/lib/api-client";
|
|
4
|
+
import { useProjectStore } from "@/stores/project-store";
|
|
5
|
+
import { useShallow } from "zustand/react/shallow";
|
|
6
|
+
import { ScrollArea } from "@/components/ui/scroll-area";
|
|
7
|
+
import { GitRefBadge, buildRefBadges } from "./git-ref-badge";
|
|
8
|
+
import type { GitGraphData, GitCommit, GitBranch } from "../../../types/git";
|
|
9
|
+
|
|
10
|
+
const PAGE_SIZE = 100;
|
|
11
|
+
|
|
12
|
+
interface GitLogPanelProps {
|
|
13
|
+
metadata?: Record<string, unknown>;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function GitLogPanel({ metadata }: GitLogPanelProps) {
|
|
17
|
+
const projectName = (metadata?.projectName as string) ??
|
|
18
|
+
useProjectStore(useShallow((s) => s.activeProject))?.name;
|
|
19
|
+
const [data, setData] = useState<GitGraphData | null>(null);
|
|
20
|
+
const [loading, setLoading] = useState(false);
|
|
21
|
+
const [hasMore, setHasMore] = useState(true);
|
|
22
|
+
const scrollRef = useRef<HTMLDivElement>(null);
|
|
23
|
+
|
|
24
|
+
const fetchData = useCallback(async (skip = 0, append = false) => {
|
|
25
|
+
if (!projectName) return;
|
|
26
|
+
setLoading(true);
|
|
27
|
+
try {
|
|
28
|
+
const res = await api.get<GitGraphData>(
|
|
29
|
+
`${projectUrl(projectName)}/git/graph?max=${PAGE_SIZE}&skip=${skip}`,
|
|
30
|
+
);
|
|
31
|
+
setData((prev) => {
|
|
32
|
+
if (append && prev) {
|
|
33
|
+
return {
|
|
34
|
+
...res,
|
|
35
|
+
commits: [...prev.commits, ...res.commits],
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
return res;
|
|
39
|
+
});
|
|
40
|
+
setHasMore(res.commits.length === PAGE_SIZE);
|
|
41
|
+
} catch {
|
|
42
|
+
/* silent */
|
|
43
|
+
}
|
|
44
|
+
setLoading(false);
|
|
45
|
+
}, [projectName]);
|
|
46
|
+
|
|
47
|
+
useEffect(() => {
|
|
48
|
+
fetchData();
|
|
49
|
+
}, [fetchData]);
|
|
50
|
+
|
|
51
|
+
const loadMore = useCallback(() => {
|
|
52
|
+
if (loading || !hasMore || !data) return;
|
|
53
|
+
fetchData(data.commits.length, true);
|
|
54
|
+
}, [loading, hasMore, data, fetchData]);
|
|
55
|
+
|
|
56
|
+
// Infinite scroll: load more when near bottom
|
|
57
|
+
const handleScroll = useCallback((e: React.UIEvent<HTMLDivElement>) => {
|
|
58
|
+
const target = e.currentTarget;
|
|
59
|
+
if (target.scrollHeight - target.scrollTop - target.clientHeight < 200) {
|
|
60
|
+
loadMore();
|
|
61
|
+
}
|
|
62
|
+
}, [loadMore]);
|
|
63
|
+
|
|
64
|
+
if (!projectName) {
|
|
65
|
+
return (
|
|
66
|
+
<div className="flex items-center justify-center h-full text-text-secondary text-sm">
|
|
67
|
+
Select a project to view git log
|
|
68
|
+
</div>
|
|
69
|
+
);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
return (
|
|
73
|
+
<div className="flex flex-col h-full">
|
|
74
|
+
{/* Header */}
|
|
75
|
+
<div className="flex items-center gap-2 px-3 py-2 border-b border-border shrink-0">
|
|
76
|
+
<GitCommitHorizontal className="size-4 text-text-secondary" />
|
|
77
|
+
<span className="text-xs font-medium text-text-primary">Git Log</span>
|
|
78
|
+
<span className="text-[10px] text-text-subtle">
|
|
79
|
+
{data ? `${data.commits.length} commits` : ""}
|
|
80
|
+
</span>
|
|
81
|
+
<div className="flex-1" />
|
|
82
|
+
<button
|
|
83
|
+
onClick={() => fetchData()}
|
|
84
|
+
disabled={loading}
|
|
85
|
+
className="p-1 rounded text-text-subtle hover:text-text-secondary transition-colors"
|
|
86
|
+
title="Refresh"
|
|
87
|
+
>
|
|
88
|
+
<RefreshCw className={`size-3.5 ${loading ? "animate-spin" : ""}`} />
|
|
89
|
+
</button>
|
|
90
|
+
</div>
|
|
91
|
+
|
|
92
|
+
{/* Commit list */}
|
|
93
|
+
{!data && loading ? (
|
|
94
|
+
<div className="flex items-center justify-center h-full">
|
|
95
|
+
<Loader2 className="size-5 animate-spin text-primary" />
|
|
96
|
+
</div>
|
|
97
|
+
) : data && data.commits.length === 0 ? (
|
|
98
|
+
<div className="flex items-center justify-center h-full text-text-secondary text-sm">
|
|
99
|
+
No commits yet
|
|
100
|
+
</div>
|
|
101
|
+
) : data ? (
|
|
102
|
+
<div
|
|
103
|
+
ref={scrollRef}
|
|
104
|
+
className="flex-1 overflow-y-auto min-h-0"
|
|
105
|
+
onScroll={handleScroll}
|
|
106
|
+
>
|
|
107
|
+
{data.commits.map((commit) => (
|
|
108
|
+
<CommitRow
|
|
109
|
+
key={commit.hash}
|
|
110
|
+
commit={commit}
|
|
111
|
+
branches={data.branches}
|
|
112
|
+
head={data.head}
|
|
113
|
+
/>
|
|
114
|
+
))}
|
|
115
|
+
{loading && (
|
|
116
|
+
<div className="flex items-center justify-center py-3">
|
|
117
|
+
<Loader2 className="size-4 animate-spin text-text-subtle" />
|
|
118
|
+
</div>
|
|
119
|
+
)}
|
|
120
|
+
{!hasMore && data.commits.length > 0 && (
|
|
121
|
+
<div className="text-center text-[10px] text-text-subtle py-3">
|
|
122
|
+
End of history
|
|
123
|
+
</div>
|
|
124
|
+
)}
|
|
125
|
+
</div>
|
|
126
|
+
) : null}
|
|
127
|
+
</div>
|
|
128
|
+
);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function CommitRow({
|
|
132
|
+
commit,
|
|
133
|
+
branches,
|
|
134
|
+
head,
|
|
135
|
+
}: {
|
|
136
|
+
commit: GitCommit;
|
|
137
|
+
branches: GitBranch[];
|
|
138
|
+
head: string;
|
|
139
|
+
}) {
|
|
140
|
+
const isHead = commit.hash === head;
|
|
141
|
+
const badges = buildRefBadges(commit.hash, commit.refs, branches, head);
|
|
142
|
+
|
|
143
|
+
return (
|
|
144
|
+
<div
|
|
145
|
+
className={`flex items-start gap-2 px-3 py-1.5 border-b border-border/50 hover:bg-surface-elevated transition-colors ${
|
|
146
|
+
isHead ? "bg-primary/5" : ""
|
|
147
|
+
}`}
|
|
148
|
+
>
|
|
149
|
+
{/* Commit dot */}
|
|
150
|
+
<div className="flex items-center pt-1 shrink-0">
|
|
151
|
+
<span
|
|
152
|
+
className={`size-2.5 rounded-full border-2 ${
|
|
153
|
+
isHead
|
|
154
|
+
? "border-primary bg-primary"
|
|
155
|
+
: "border-text-subtle bg-transparent"
|
|
156
|
+
}`}
|
|
157
|
+
/>
|
|
158
|
+
</div>
|
|
159
|
+
|
|
160
|
+
{/* Content */}
|
|
161
|
+
<div className="flex-1 min-w-0">
|
|
162
|
+
{/* Ref badges row */}
|
|
163
|
+
{badges.length > 0 && (
|
|
164
|
+
<div className="flex flex-wrap items-center gap-1 mb-0.5">
|
|
165
|
+
{badges.map((badge) => (
|
|
166
|
+
<GitRefBadge key={`${badge.name}-${badge.syncState}`} {...badge} />
|
|
167
|
+
))}
|
|
168
|
+
</div>
|
|
169
|
+
)}
|
|
170
|
+
|
|
171
|
+
{/* Subject line */}
|
|
172
|
+
<p className="text-xs text-text-primary truncate leading-snug">
|
|
173
|
+
{commit.subject}
|
|
174
|
+
</p>
|
|
175
|
+
|
|
176
|
+
{/* Meta */}
|
|
177
|
+
<div className="flex items-center gap-2 mt-0.5">
|
|
178
|
+
<span className="text-[10px] font-mono text-text-subtle">
|
|
179
|
+
{commit.abbreviatedHash}
|
|
180
|
+
</span>
|
|
181
|
+
<span className="text-[10px] text-text-subtle truncate">
|
|
182
|
+
{commit.authorName}
|
|
183
|
+
</span>
|
|
184
|
+
<span className="text-[10px] text-text-subtle shrink-0">
|
|
185
|
+
{formatRelativeDate(commit.authorDate)}
|
|
186
|
+
</span>
|
|
187
|
+
</div>
|
|
188
|
+
</div>
|
|
189
|
+
</div>
|
|
190
|
+
);
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/** Compact relative date: "2h", "3d", "Jan 5" */
|
|
194
|
+
function formatRelativeDate(dateStr: string): string {
|
|
195
|
+
const date = new Date(dateStr);
|
|
196
|
+
const now = Date.now();
|
|
197
|
+
const diff = now - date.getTime();
|
|
198
|
+
const mins = Math.floor(diff / 60000);
|
|
199
|
+
if (mins < 1) return "now";
|
|
200
|
+
if (mins < 60) return `${mins}m`;
|
|
201
|
+
const hours = Math.floor(mins / 60);
|
|
202
|
+
if (hours < 24) return `${hours}h`;
|
|
203
|
+
const days = Math.floor(hours / 24);
|
|
204
|
+
if (days < 30) return `${days}d`;
|
|
205
|
+
return date.toLocaleDateString("en", { month: "short", day: "numeric" });
|
|
206
|
+
}
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
import { Cloud, CloudOff, Tag, ArrowUp, ArrowDown } from "lucide-react";
|
|
2
|
+
import type { GitBranch } from "../../../types/git";
|
|
3
|
+
import { cn } from "@/lib/utils";
|
|
4
|
+
|
|
5
|
+
/** Deterministic color from branch name — 8 distinct hues */
|
|
6
|
+
const BRANCH_COLORS = [
|
|
7
|
+
"#2563eb", // blue
|
|
8
|
+
"#16a34a", // green
|
|
9
|
+
"#d97706", // amber
|
|
10
|
+
"#9333ea", // purple
|
|
11
|
+
"#dc2626", // red
|
|
12
|
+
"#0891b2", // cyan
|
|
13
|
+
"#c026d3", // fuchsia
|
|
14
|
+
"#ea580c", // orange
|
|
15
|
+
];
|
|
16
|
+
|
|
17
|
+
function hashColor(name: string): string {
|
|
18
|
+
let hash = 0;
|
|
19
|
+
for (let i = 0; i < name.length; i++) {
|
|
20
|
+
hash = (hash * 31 + name.charCodeAt(i)) | 0;
|
|
21
|
+
}
|
|
22
|
+
return BRANCH_COLORS[Math.abs(hash) % BRANCH_COLORS.length]!;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export type RefSyncState = "synced" | "local-only" | "remote-only" | "ahead" | "behind";
|
|
26
|
+
|
|
27
|
+
interface GitRefBadgeProps {
|
|
28
|
+
/** Display name (e.g. "main", "feature/login") */
|
|
29
|
+
name: string;
|
|
30
|
+
syncState: RefSyncState;
|
|
31
|
+
/** True if this is HEAD */
|
|
32
|
+
isHead?: boolean;
|
|
33
|
+
/** True if this is a tag ref */
|
|
34
|
+
isTag?: boolean;
|
|
35
|
+
className?: string;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Merged branch/tag badge — colored border + light bg + black text */
|
|
39
|
+
export function GitRefBadge({ name, syncState, isHead, isTag, className }: GitRefBadgeProps) {
|
|
40
|
+
const color = isTag ? "#d97706" : hashColor(name);
|
|
41
|
+
|
|
42
|
+
const SyncIcon = isTag
|
|
43
|
+
? Tag
|
|
44
|
+
: syncState === "synced"
|
|
45
|
+
? Cloud
|
|
46
|
+
: syncState === "ahead"
|
|
47
|
+
? ArrowUp
|
|
48
|
+
: syncState === "behind"
|
|
49
|
+
? ArrowDown
|
|
50
|
+
: syncState === "remote-only"
|
|
51
|
+
? Cloud
|
|
52
|
+
: CloudOff;
|
|
53
|
+
|
|
54
|
+
const title = isTag
|
|
55
|
+
? `Tag: ${name}`
|
|
56
|
+
: syncState === "synced"
|
|
57
|
+
? `${name} — synced with remote`
|
|
58
|
+
: syncState === "ahead"
|
|
59
|
+
? `${name} — ahead of remote`
|
|
60
|
+
: syncState === "behind"
|
|
61
|
+
? `${name} — behind remote`
|
|
62
|
+
: syncState === "remote-only"
|
|
63
|
+
? `${name} — remote only`
|
|
64
|
+
: `${name} — local only`;
|
|
65
|
+
|
|
66
|
+
return (
|
|
67
|
+
<span
|
|
68
|
+
className={cn(
|
|
69
|
+
"inline-flex items-center gap-1 rounded-md border px-1.5 py-0.5 text-[11px] font-medium leading-tight max-w-[220px]",
|
|
70
|
+
isHead && "ring-1 ring-offset-1 ring-offset-background",
|
|
71
|
+
syncState === "remote-only" && "opacity-70",
|
|
72
|
+
className,
|
|
73
|
+
)}
|
|
74
|
+
style={{
|
|
75
|
+
borderColor: color,
|
|
76
|
+
backgroundColor: color + "1A", // ~10% opacity
|
|
77
|
+
color: "#1a1a1a",
|
|
78
|
+
// HEAD ring color
|
|
79
|
+
...(isHead ? { ["--tw-ring-color" as string]: color } : {}),
|
|
80
|
+
}}
|
|
81
|
+
title={title}
|
|
82
|
+
>
|
|
83
|
+
<SyncIcon className="size-3 shrink-0" style={{ color }} />
|
|
84
|
+
<span className="truncate">{name}</span>
|
|
85
|
+
</span>
|
|
86
|
+
);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** Merge raw commit refs + branch data into deduplicated badge props */
|
|
90
|
+
export function buildRefBadges(
|
|
91
|
+
commitHash: string,
|
|
92
|
+
refs: string[],
|
|
93
|
+
branches: GitBranch[],
|
|
94
|
+
head: string,
|
|
95
|
+
): GitRefBadgeProps[] {
|
|
96
|
+
const badges: GitRefBadgeProps[] = [];
|
|
97
|
+
const isHead = commitHash === head;
|
|
98
|
+
|
|
99
|
+
// Collect tags from refs
|
|
100
|
+
for (const ref of refs) {
|
|
101
|
+
if (ref.startsWith("tag: ")) {
|
|
102
|
+
badges.push({ name: ref.slice(5), syncState: "synced", isTag: true });
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// Group branches by logical name (strip remotes/origin/ prefix)
|
|
107
|
+
const branchesOnCommit = branches.filter((b) => b.commitHash === commitHash);
|
|
108
|
+
const localBranches = branchesOnCommit.filter((b) => !b.remote);
|
|
109
|
+
const remoteBranches = branchesOnCommit.filter((b) => b.remote);
|
|
110
|
+
|
|
111
|
+
// Build set of remote branch names that have a local counterpart
|
|
112
|
+
const localNames = new Set(localBranches.map((b) => b.name));
|
|
113
|
+
const mergedRemoteNames = new Set<string>();
|
|
114
|
+
|
|
115
|
+
// Add local branches with sync state
|
|
116
|
+
for (const local of localBranches) {
|
|
117
|
+
let syncState: RefSyncState = "local-only";
|
|
118
|
+
if (local.remotes.length > 0) {
|
|
119
|
+
// Has remote tracking
|
|
120
|
+
if (local.ahead > 0 && local.behind === 0) syncState = "ahead";
|
|
121
|
+
else if (local.behind > 0 && local.ahead === 0) syncState = "behind";
|
|
122
|
+
else syncState = "synced";
|
|
123
|
+
}
|
|
124
|
+
badges.push({
|
|
125
|
+
name: local.name,
|
|
126
|
+
syncState,
|
|
127
|
+
isHead: isHead && local.current,
|
|
128
|
+
});
|
|
129
|
+
// Mark corresponding remote names as merged
|
|
130
|
+
for (const remote of remoteBranches) {
|
|
131
|
+
const stripped = remote.name.replace(/^remotes\//, "");
|
|
132
|
+
const slashIdx = stripped.indexOf("/");
|
|
133
|
+
if (slashIdx < 0) continue;
|
|
134
|
+
const remoteBranchName = stripped.slice(slashIdx + 1);
|
|
135
|
+
if (remoteBranchName === local.name) {
|
|
136
|
+
mergedRemoteNames.add(remote.name);
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// Add remote-only branches (not merged with any local)
|
|
142
|
+
for (const remote of remoteBranches) {
|
|
143
|
+
if (mergedRemoteNames.has(remote.name)) continue;
|
|
144
|
+
// Strip "remotes/" prefix for display, keep "origin/..." format
|
|
145
|
+
const displayName = remote.name.replace(/^remotes\//, "");
|
|
146
|
+
badges.push({ name: displayName, syncState: "remote-only" });
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
return badges;
|
|
150
|
+
}
|
|
@@ -12,6 +12,7 @@ import {
|
|
|
12
12
|
ChevronRight,
|
|
13
13
|
ChevronDown,
|
|
14
14
|
FileText,
|
|
15
|
+
GitCommitHorizontal,
|
|
15
16
|
} from "lucide-react";
|
|
16
17
|
import { api, projectUrl } from "@/lib/api-client";
|
|
17
18
|
import { basename } from "@/lib/utils";
|
|
@@ -330,6 +331,23 @@ export function GitStatusPanel({ metadata, tabId, onNavigate }: GitStatusPanelPr
|
|
|
330
331
|
>
|
|
331
332
|
<FolderTree className="size-3.5" />
|
|
332
333
|
</Button>
|
|
334
|
+
<Button
|
|
335
|
+
variant="ghost"
|
|
336
|
+
size="icon-xs"
|
|
337
|
+
onClick={() => {
|
|
338
|
+
openTab({
|
|
339
|
+
type: "git-log",
|
|
340
|
+
title: "Git Log",
|
|
341
|
+
projectId: projectName ?? null,
|
|
342
|
+
closable: true,
|
|
343
|
+
metadata: { projectName },
|
|
344
|
+
});
|
|
345
|
+
onNavigate?.();
|
|
346
|
+
}}
|
|
347
|
+
title="View Git Log"
|
|
348
|
+
>
|
|
349
|
+
<GitCommitHorizontal className="size-3.5" />
|
|
350
|
+
</Button>
|
|
333
351
|
<Button
|
|
334
352
|
variant="ghost"
|
|
335
353
|
size="icon-xs"
|
|
@@ -35,6 +35,8 @@ const TAB_ICONS: Record<TabType, React.ElementType> = {
|
|
|
35
35
|
extension: Puzzle,
|
|
36
36
|
"extension-webview": Puzzle,
|
|
37
37
|
"conflict-editor": FileDiff,
|
|
38
|
+
"system-monitor": Settings,
|
|
39
|
+
"git-log": FileCode,
|
|
38
40
|
};
|
|
39
41
|
|
|
40
42
|
interface MobileNavProps { onMenuPress: () => void; onProjectsPress: () => void; }
|
|
@@ -13,6 +13,7 @@ import { ExtensionTreeView } from "@/components/extensions/extension-tree-view";
|
|
|
13
13
|
import { JiraPanel } from "@/components/jira/jira-panel";
|
|
14
14
|
import { useGitStatusStore, useGitChangesPoller } from "@/stores/git-status-store";
|
|
15
15
|
import { useJiraStore } from "@/stores/jira-store";
|
|
16
|
+
import { ResourceStatusBar } from "@/components/system/resource-status-bar";
|
|
16
17
|
import { cn } from "@/lib/utils";
|
|
17
18
|
|
|
18
19
|
const BUILTIN_TABS: { id: SidebarActiveTab; label: string; icon: React.ElementType }[] = [
|
|
@@ -183,6 +184,11 @@ export const Sidebar = memo(function Sidebar() {
|
|
|
183
184
|
)}
|
|
184
185
|
</div>
|
|
185
186
|
|
|
187
|
+
{/* Resource monitor status bar */}
|
|
188
|
+
<div className="shrink-0 border-t border-border">
|
|
189
|
+
<ResourceStatusBar />
|
|
190
|
+
</div>
|
|
191
|
+
|
|
186
192
|
{/* Resize handle */}
|
|
187
193
|
<ResizeHandle onResize={setSidebarWidth} />
|
|
188
194
|
</aside>
|