@hienlh/ppm 0.13.54 → 0.13.56

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 (123) 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 +8 -1
  4. package/dist/web/assets/ai-settings-section-BH2UOQH-.js +1 -0
  5. package/dist/web/assets/{api-settings-C3T95dWg.js → api-settings-uQKmeGkl.js} +1 -1
  6. package/dist/web/assets/architecture-PBZL5I3N-DLKD1Xjj.js +1 -0
  7. package/dist/web/assets/arrow-down-D825m4vm.js +1 -0
  8. package/dist/web/assets/{audio-preview-BF1LU0eY.js → audio-preview-LCxWo-25.js} +1 -1
  9. package/dist/web/assets/chat-tab-3GUmdAJN.js +16 -0
  10. package/dist/web/assets/chevron-down-BMo4cBth.js +1 -0
  11. package/dist/web/assets/code-editor-Cd7NZ0VX.js +8 -0
  12. package/dist/web/assets/{conflict-editor-DcVj0Z-q.js → conflict-editor-No7IVPRN.js} +3 -3
  13. package/dist/web/assets/csv-parser-D8VHWVA6.js +6 -0
  14. package/dist/web/assets/{csv-preview-B3Dyhgho.js → csv-preview-DgArUJhd.js} +2 -2
  15. package/dist/web/assets/{data-grid-overlay-editor-C1UUm7Ob.js → data-grid-overlay-editor-CmduzuPM.js} +1 -1
  16. package/dist/web/assets/database-viewer-zeQXxkg-.js +1 -0
  17. package/dist/web/assets/diff-viewer-DmSkyBaT.js +4 -0
  18. package/dist/web/assets/{esm-DCbn6xno.js → esm-JPvheKDJ.js} +1 -1
  19. package/dist/web/assets/{extension-webview-DCmfZH6p.js → extension-webview-DCwz2Wso.js} +1 -1
  20. package/dist/web/assets/eye-off-BacF7RVS.js +1 -0
  21. package/dist/web/assets/git-log-panel-Bk7Eh-Bn.js +1 -0
  22. package/dist/web/assets/gitGraph-HDMCJU4V-2a0r4GHr.js +1 -0
  23. package/dist/web/assets/glide-data-grid-ha8hjunf.js +136 -0
  24. package/dist/web/assets/{image-preview-BIJGvZ5-.js → image-preview-C1VLHLdJ.js} +1 -1
  25. package/dist/web/assets/index-CP6EIXkh.js +27 -0
  26. package/dist/web/assets/index-DwvSM9vu.css +2 -0
  27. package/dist/web/assets/info-3K5VOQVL-CWKw4e0V.js +1 -0
  28. package/dist/web/assets/{input-_LFQwhzd.js → input-B78ol0hV.js} +1 -1
  29. package/dist/web/assets/keybindings-store-Rf9YKWAp.js +1 -0
  30. package/dist/web/assets/{markdown-renderer-CwKRCQuc.js → markdown-renderer-C7ZoGWso.js} +3 -3
  31. package/dist/web/assets/notification-store-C3tg-ZXm.js +1 -0
  32. package/dist/web/assets/{number-overlay-editor-CyEqxXcg.js → number-overlay-editor-DS-qf63L.js} +1 -1
  33. package/dist/web/assets/packet-RMMSAZCW-Ar00Wbhd.js +1 -0
  34. package/dist/web/assets/panel-store-B1pOXkyS.js +1 -0
  35. package/dist/web/assets/{pdf-preview-CbUTv4dX.js → pdf-preview-BdmR2RsT.js} +1 -1
  36. package/dist/web/assets/pie-UPGHQEXC-Q4ssDdib.js +1 -0
  37. package/dist/web/assets/port-forwarding-tab-BMb8neu0.js +1 -0
  38. package/dist/web/assets/{postgres-viewer-C-A4MMtt.js → postgres-viewer-DkM6ndux.js} +3 -3
  39. package/dist/web/assets/project-store-BnvrVKBw.js +1 -0
  40. package/dist/web/assets/radar-KQ55EAFF-kq5v4OKX.js +1 -0
  41. package/dist/web/assets/{settings-store-8FpQDjEA.js → settings-store-CSDOihqv.js} +2 -2
  42. package/dist/web/assets/settings-tab-WdL5pYxG.js +1 -0
  43. package/dist/web/assets/{sql-query-editor-Cu9mYyfb.js → sql-query-editor-CNwOnFgF.js} +1 -1
  44. package/dist/web/assets/{sqlite-viewer-D6ngJJgP.js → sqlite-viewer-FFlsRPWf.js} +1 -1
  45. package/dist/web/assets/system-monitor-tab-CefzxLVi.js +1 -0
  46. package/dist/web/assets/{tab-store-CNas5Ny8.js → tab-store-DzftzxTL.js} +1 -1
  47. package/dist/web/assets/{terminal-tab-CyuBxW2x.js → terminal-tab-C4MNU5S3.js} +2 -2
  48. package/dist/web/assets/{x-BPReZWnP.js → trash-2-DkIfBY8d.js} +1 -1
  49. package/dist/web/assets/treemap-KZPCXAKY-DChODgHt.js +1 -0
  50. package/dist/web/assets/{use-blob-url-DB4nNruT.js → use-blob-url-CI2h4qu0.js} +1 -1
  51. package/dist/web/assets/{use-monaco-theme-DEI-tJAh.js → use-monaco-theme-qx6SfVRk.js} +1 -1
  52. package/dist/web/assets/{vendor-mermaid-D2KKkqNs.js → vendor-mermaid-DCie7hiR.js} +2 -2
  53. package/dist/web/assets/{video-preview-ChP5ypMo.js → video-preview-izCy3xj7.js} +1 -1
  54. package/dist/web/assets/wifi-LJEyIdXf.js +1 -0
  55. package/dist/web/assets/x-DfF6D5Js.js +1 -0
  56. package/dist/web/index.html +22 -20
  57. package/dist/web/sw.js +1 -1
  58. package/docs/project-changelog.md +38 -1
  59. package/docs/system-architecture.md +2 -1
  60. package/package.json +1 -1
  61. package/packages/ext-git-graph/src/webview-html.ts +29 -8
  62. package/src/server/index.ts +4 -0
  63. package/src/server/middleware/auth.ts +9 -0
  64. package/src/server/routes/resources.ts +88 -0
  65. package/src/services/resource-monitor-utils.ts +129 -0
  66. package/src/services/resource-monitor.service.ts +122 -0
  67. package/src/web/components/git/git-log-panel.tsx +206 -0
  68. package/src/web/components/git/git-ref-badge.tsx +150 -0
  69. package/src/web/components/git/git-status-panel.tsx +18 -0
  70. package/src/web/components/layout/mobile-nav.tsx +2 -0
  71. package/src/web/components/layout/sidebar.tsx +6 -0
  72. package/src/web/components/layout/tab-bar.tsx +3 -0
  73. package/src/web/components/layout/tab-content.tsx +10 -0
  74. package/src/web/components/layout/tab-pool.tsx +2 -0
  75. package/src/web/components/system/resource-status-bar.tsx +66 -0
  76. package/src/web/components/system/sparkline-canvas.tsx +64 -0
  77. package/src/web/components/system/system-monitor-group-row.tsx +133 -0
  78. package/src/web/components/system/system-monitor-tab.tsx +186 -0
  79. package/src/web/hooks/use-resource-monitor.ts +114 -0
  80. package/src/web/stores/panel-store.ts +1 -1
  81. package/src/web/stores/tab-store.ts +3 -1
  82. package/dist/web/assets/ai-settings-section-AuV6Lzz2.js +0 -1
  83. package/dist/web/assets/architecture-PBZL5I3N-fAJMexNi.js +0 -1
  84. package/dist/web/assets/chat-tab-CCOkAmh8.js +0 -16
  85. package/dist/web/assets/code-editor-BptkAFVa.js +0 -8
  86. package/dist/web/assets/csv-parser-Dly5nqE1.js +0 -6
  87. package/dist/web/assets/database-viewer-CYrsNjRy.js +0 -1
  88. package/dist/web/assets/diff-viewer-DMBviO6l.js +0 -4
  89. package/dist/web/assets/file-store-DOxcU_7s.js +0 -1
  90. package/dist/web/assets/gitGraph-HDMCJU4V-ClRAoBAn.js +0 -1
  91. package/dist/web/assets/glide-data-grid-DhZjCUqu.js +0 -136
  92. package/dist/web/assets/index-BA8zQtSN.js +0 -27
  93. package/dist/web/assets/index-CKKoR3gY.css +0 -2
  94. package/dist/web/assets/info-3K5VOQVL-D8uyBfC_.js +0 -1
  95. package/dist/web/assets/keybindings-store-BXumit4n.js +0 -1
  96. package/dist/web/assets/notification-store-B3Fgo6Qw.js +0 -1
  97. package/dist/web/assets/packet-RMMSAZCW-BJj3FH_w.js +0 -1
  98. package/dist/web/assets/panel-store-C8wwxBpn.js +0 -1
  99. package/dist/web/assets/pie-UPGHQEXC-BKfHRBz7.js +0 -1
  100. package/dist/web/assets/port-forwarding-tab-Nn3-C-Vu.js +0 -1
  101. package/dist/web/assets/radar-KQ55EAFF-DkybKTt9.js +0 -1
  102. package/dist/web/assets/scroll-area-BDi_FNzr.js +0 -1
  103. package/dist/web/assets/settings-tab-Bzlcvim9.js +0 -1
  104. package/dist/web/assets/treemap-KZPCXAKY-BvDgIWW9.js +0 -1
  105. /package/dist/web/assets/{api-client-DIhJ5qVW.js → api-client-DiZgVOok.js} +0 -0
  106. /package/dist/web/assets/{chevron-right-DnHIvvcy.js → chevron-right-CD8e6Aj4.js} +0 -0
  107. /package/dist/web/assets/{code-DGBecc50.js → code-DiNmA3eR.js} +0 -0
  108. /package/dist/web/assets/{data-grid-types-D2cHE8hx.js → data-grid-types-C29KDkZJ.js} +0 -0
  109. /package/dist/web/assets/{database-DOWH9-Vv.js → database-Dc8mr-dP.js} +0 -0
  110. /package/dist/web/assets/{dist-DVk8T0R5.js → dist-DeY41KFi.js} +0 -0
  111. /package/dist/web/assets/{dist-BoIkGNC8.js → dist-PPUhQONj.js} +0 -0
  112. /package/dist/web/assets/{file-exclamation-point-BwzaQ50n.js → file-exclamation-point-B__2Hrd6.js} +0 -0
  113. /package/dist/web/assets/{globe-B4Ilypbs.js → globe-CQ8NAYvi.js} +0 -0
  114. /package/dist/web/assets/{katex-C10ndCVt.js → katex-DUj5OG1J.js} +0 -0
  115. /package/dist/web/assets/{lib-C2D8j3K3.js → lib-DrypSCq8.js} +0 -0
  116. /package/dist/web/assets/{react-DMIOAtcX.js → react-CfveccaI.js} +0 -0
  117. /package/dist/web/assets/{refresh-cw-BjrAbUJe.js → refresh-cw-CRD2qr4U.js} +0 -0
  118. /package/dist/web/assets/{search-tM8K5zWU.js → search-D90WJ5fo.js} +0 -0
  119. /package/dist/web/assets/{sparkles-CulWHe4c.js → sparkles-KCOEy7QI.js} +0 -0
  120. /package/dist/web/assets/{table-BzjWcs87.js → table-2wDtM4_B.js} +0 -0
  121. /package/dist/web/assets/{text-wrap-DJz9Bgpa.js → text-wrap-AZErifCu.js} +0 -0
  122. /package/dist/web/assets/{utils-CQux7CsO.js → utils-E0yyGxXt.js} +0 -0
  123. /package/dist/web/assets/{vendor-xterm-D1P36hcr.js → vendor-xterm-t3d5xZdz.js} +0 -0
@@ -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();
@@ -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>
@@ -11,6 +11,7 @@ import {
11
11
  ChevronRight,
12
12
  Globe,
13
13
  Puzzle,
14
+ GitCommitHorizontal,
14
15
  } from "lucide-react";
15
16
  import { useTabStore, type TabType } from "@/stores/tab-store";
16
17
  import { usePanelStore } from "@/stores/panel-store";
@@ -52,6 +53,8 @@ const TAB_ICONS: Record<TabType, React.ElementType> = {
52
53
  extension: Puzzle,
53
54
  "extension-webview": Puzzle,
54
55
  "conflict-editor": FileDiff,
56
+ "system-monitor": Settings,
57
+ "git-log": GitCommitHorizontal,
55
58
  };
56
59
 
57
60
  interface TabBarProps {
@@ -64,6 +64,16 @@ const TAB_COMPONENTS: Record<TabType, React.LazyExoticComponent<React.ComponentT
64
64
  default: m.ConflictEditor,
65
65
  })),
66
66
  ),
67
+ "system-monitor": lazy(() =>
68
+ import("@/components/system/system-monitor-tab").then((m) => ({
69
+ default: m.SystemMonitorTab,
70
+ })),
71
+ ),
72
+ "git-log": lazy(() =>
73
+ import("@/components/git/git-log-panel").then((m) => ({
74
+ default: m.GitLogPanel,
75
+ })),
76
+ ),
67
77
  };
68
78
 
69
79
  function LoadingFallback() {
@@ -31,6 +31,8 @@ const TAB_COMPONENTS: Record<TabType, React.LazyExoticComponent<React.ComponentT
31
31
  extension: lazy(() => import("@/components/extensions/extension-webview").then((m) => ({ default: m.ExtensionWebview }))),
32
32
  "extension-webview": lazy(() => import("@/components/extensions/extension-webview").then((m) => ({ default: m.ExtensionWebview }))),
33
33
  "conflict-editor": lazy(() => import("@/components/editor/conflict-editor").then((m) => ({ default: m.ConflictEditor }))),
34
+ "system-monitor": lazy(() => import("@/components/system/system-monitor-tab").then((m) => ({ default: m.SystemMonitorTab }))),
35
+ "git-log": lazy(() => import("@/components/git/git-log-panel").then((m) => ({ default: m.GitLogPanel }))),
34
36
  };
35
37
 
36
38
  // ---------------------------------------------------------------------------
@@ -0,0 +1,66 @@
1
+ import { memo } from "react";
2
+ import { Cpu } from "lucide-react";
3
+ import { useResourceMonitor } from "@/hooks/use-resource-monitor";
4
+ import { useTabStore } from "@/stores/tab-store";
5
+ import { useIsMobile } from "@/hooks/use-is-mobile";
6
+ import { cn } from "@/lib/utils";
7
+
8
+ function cpuColor(cpu: number) {
9
+ if (cpu > 80) return "text-red-500";
10
+ if (cpu > 50) return "text-yellow-500";
11
+ return "text-green-500";
12
+ }
13
+
14
+ export const ResourceStatusBar = memo(function ResourceStatusBar() {
15
+ const { latest, isConnected } = useResourceMonitor();
16
+ const openTab = useTabStore((s) => s.openTab);
17
+ const isMobile = useIsMobile();
18
+
19
+ const handleClick = () => {
20
+ openTab({
21
+ type: "system-monitor",
22
+ title: "System Monitor",
23
+ projectId: null,
24
+ closable: true,
25
+ });
26
+ };
27
+
28
+ if (!isConnected || !latest) {
29
+ return (
30
+ <button
31
+ onClick={handleClick}
32
+ className="flex items-center gap-1.5 px-2 py-1 text-[10px] text-text-subtle hover:text-text-secondary transition-colors w-full"
33
+ >
34
+ <Cpu className="size-3 opacity-50" />
35
+ <span className="opacity-50">Connecting...</span>
36
+ </button>
37
+ );
38
+ }
39
+
40
+ const { cpu, ramMB, processCount } = latest.total;
41
+
42
+ return (
43
+ <button
44
+ onClick={handleClick}
45
+ className="flex items-center gap-1.5 px-2 py-1 text-[10px] hover:bg-surface-hover transition-colors w-full cursor-pointer"
46
+ title="Open System Monitor"
47
+ >
48
+ <Cpu className={cn("size-3", cpuColor(cpu))} />
49
+ <span className={cpuColor(cpu)}>
50
+ {isMobile ? `${cpu.toFixed(0)}%` : `CPU ${cpu.toFixed(1)}%`}
51
+ </span>
52
+ <span className="text-text-subtle">|</span>
53
+ <span className="text-text-secondary">
54
+ {ramMB < 1024
55
+ ? `${ramMB.toFixed(0)}MB`
56
+ : `${(ramMB / 1024).toFixed(1)}GB`}
57
+ </span>
58
+ {!isMobile && (
59
+ <>
60
+ <span className="text-text-subtle">|</span>
61
+ <span className="text-text-subtle">{processCount} proc</span>
62
+ </>
63
+ )}
64
+ </button>
65
+ );
66
+ });