@hienlh/ppm 0.9.86 → 0.9.87

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 (52) hide show
  1. package/260415-0932-git-graph-stash-rebase-conflicts/reports/code-reviewer-260415-1020-stash-rebase-conflicts.md +288 -0
  2. package/260415-0932-git-graph-stash-rebase-conflicts/reports/tester-260415-1020-build-check.md +117 -0
  3. package/260415-1150-ext-silent-failure-debugging/reports/code-reviewer-260415-1159-ext-error-reporting-review.md +205 -0
  4. package/260415-1150-ext-silent-failure-debugging/reports/docs-manager-260415-1206-ext-error-reporting.md +99 -0
  5. package/260415-1150-ext-silent-failure-debugging/reports/tester-260415-1159-extension-error-reporting.md +174 -0
  6. package/CHANGELOG.md +14 -0
  7. package/dist/web/assets/{chat-tab-BEEd-Km4.js → chat-tab-R4gKsnxD.js} +1 -1
  8. package/dist/web/assets/{code-editor-Ij4p30cr.js → code-editor-Br0vzTOy.js} +2 -2
  9. package/dist/web/assets/conflict-editor-BPgCjnNz.js +19 -0
  10. package/dist/web/assets/{csv-preview-CwQnOa3E.js → csv-preview-BZRICDP0.js} +1 -1
  11. package/dist/web/assets/{database-viewer-C1UHSgft.js → database-viewer-DaUoQ-oR.js} +1 -1
  12. package/dist/web/assets/{diff-viewer-CVx5naBA.js → diff-viewer-BzvK3gAE.js} +1 -1
  13. package/dist/web/assets/extension-webview-CGepEw-b.js +3 -0
  14. package/dist/web/assets/{index-OqgGFmh8.js → index-CKsEzQ4f.js} +4 -4
  15. package/dist/web/assets/index-Chf0otez.css +2 -0
  16. package/dist/web/assets/keybindings-store-D5zgHod8.js +1 -0
  17. package/dist/web/assets/{markdown-renderer-CRy8xw2B.js → markdown-renderer-DSYnGywb.js} +1 -1
  18. package/dist/web/assets/{port-forwarding-tab-Biua8ov5.js → port-forwarding-tab-vmqDKmk2.js} +1 -1
  19. package/dist/web/assets/{postgres-viewer-BcVjCAl4.js → postgres-viewer-0lIAosrr.js} +1 -1
  20. package/dist/web/assets/{settings-tab-C9X-N8hE.js → settings-tab-CMnv1fce.js} +1 -1
  21. package/dist/web/assets/{sql-query-editor-BFvRvJn0.js → sql-query-editor-Bc2hAwqT.js} +1 -1
  22. package/dist/web/assets/{sqlite-viewer-CPfvwFl4.js → sqlite-viewer-B60MS2Dy.js} +1 -1
  23. package/dist/web/assets/{terminal-tab-mWwk_weB.js → terminal-tab-CCJoLstH.js} +1 -1
  24. package/dist/web/assets/{use-monaco-theme-CPaeSMAA.js → use-monaco-theme-BJK48EmK.js} +1 -1
  25. package/dist/web/index.html +2 -2
  26. package/dist/web/sw.js +1 -1
  27. package/docs/codebase-summary.md +39 -6
  28. package/docs/project-changelog.md +86 -25
  29. package/docs/project-roadmap.md +3 -2
  30. package/docs/system-architecture.md +44 -1
  31. package/package.json +1 -1
  32. package/packages/ext-git-graph/src/extension.ts +126 -5
  33. package/packages/ext-git-graph/src/types.ts +13 -2
  34. package/packages/ext-git-graph/src/webview-html.ts +223 -5
  35. package/src/server/ws/extensions.ts +28 -2
  36. package/src/services/extension-host-worker.ts +6 -1
  37. package/src/services/extension.service.ts +17 -3
  38. package/src/types/extension-messages.ts +1 -1
  39. package/src/web/components/editor/conflict-editor.tsx +368 -0
  40. package/src/web/components/extensions/extension-webview.tsx +45 -3
  41. package/src/web/components/layout/editor-panel.tsx +1 -0
  42. package/src/web/components/layout/mobile-nav.tsx +1 -0
  43. package/src/web/components/layout/tab-bar.tsx +1 -0
  44. package/src/web/components/layout/tab-content.tsx +5 -0
  45. package/src/web/hooks/use-extension-ws.ts +8 -0
  46. package/src/web/stores/extension-store.ts +8 -0
  47. package/src/web/stores/panel-utils.ts +2 -0
  48. package/src/web/stores/tab-store.ts +2 -1
  49. package/dist/web/assets/extension-webview-CHVVpV34.js +0 -3
  50. package/dist/web/assets/index-vA7juDri.css +0 -2
  51. package/dist/web/assets/keybindings-store-BQxgPV5o.js +0 -1
  52. /package/dist/web/assets/{lib-CeBVkQ-7.js → lib-DSLzfeW0.js} +0 -0
@@ -0,0 +1,288 @@
1
+ # Code Review: Git Graph — Stash, Rebase, Conflicts
2
+
3
+ **Date:** 2026-04-15
4
+ **Reviewer:** code-reviewer
5
+ **Scope:** 10 files changed, ~600 LOC net new
6
+
7
+ ## Scope
8
+
9
+ | File | Role |
10
+ |------|------|
11
+ | `packages/ext-git-graph/src/types.ts` | Type definitions |
12
+ | `packages/ext-git-graph/src/extension.ts` | Backend: stash/merge-state/conflict handlers |
13
+ | `packages/ext-git-graph/src/webview-html.ts` | Webview: stash popover, merge banner, conflict UI |
14
+ | `src/web/components/editor/conflict-editor.tsx` | NEW: Monaco conflict resolution editor |
15
+ | `src/web/stores/tab-store.ts` | TabType union |
16
+ | `src/web/stores/panel-utils.ts` | `deriveTabId` for conflict-editor |
17
+ | `src/web/components/layout/editor-panel.tsx` | TAB_COMPONENTS registration |
18
+ | `src/web/components/layout/tab-content.tsx` | TAB_COMPONENTS registration |
19
+ | `src/web/components/layout/mobile-nav.tsx` | Icon mapping |
20
+ | `src/web/components/layout/tab-bar.tsx` | Icon mapping |
21
+
22
+ ## Overall Assessment
23
+
24
+ Well-structured feature addition. Security practices (assertSafeFilePaths, assertValidRef, escHtml) are consistently applied to the new surface area. Three blocking issues, four informational.
25
+
26
+ ---
27
+
28
+ ## Critical Issues
29
+
30
+ ### C1 — `detectMergeState` hardcodes `.git` path, breaks for worktrees
31
+
32
+ **File:** `extension.ts:531-562`
33
+
34
+ ```typescript
35
+ const rebaseMergeDir = `${projectPath}/.git/rebase-merge`;
36
+ const checkMerge = await vscode.process.spawn("test", ["-f", `${projectPath}/.git/MERGE_HEAD`], ...);
37
+ ```
38
+
39
+ For git worktrees, `.git` inside the worktree directory is a **file** (containing `gitdir: /path/to/main/.git/worktrees/<name>`), not a directory. The rebase/merge state is stored under `$GIT_DIR/rebase-merge`, where `$GIT_DIR` for a worktree is `<main>/.git/worktrees/<name>`. The current hardcoded path will always fail `test -d` / `test -f` for worktrees, silently returning `undefined` for `mergeState`.
40
+
41
+ The PPM app already heavily uses worktrees (worktree CRUD is a major feature of this same extension). Merge conflicts inside a worktree will show files as conflicted but the banner will never appear.
42
+
43
+ **Fix:** Use `git rev-parse --git-dir` to get the actual `GIT_DIR`, then construct paths from that:
44
+
45
+ ```typescript
46
+ async function detectMergeState(vscode, projectPath) {
47
+ const gitDirResult = await spawnGit(vscode, ["rev-parse", "--git-dir"], projectPath, { timeout: 2000 });
48
+ if (gitDirResult.exitCode !== 0) return undefined;
49
+ const gitDir = gitDirResult.stdout.trim();
50
+ const absGitDir = gitDir.startsWith("/") ? gitDir : `${projectPath}/${gitDir}`;
51
+
52
+ const checkRebase = await vscode.process.spawn("test", ["-d", `${absGitDir}/rebase-merge`], projectPath, { timeout: 2000 });
53
+ // ... rest of checks use absGitDir instead of `${projectPath}/.git`
54
+ }
55
+ ```
56
+
57
+ ---
58
+
59
+ ### C2 — `getDisplayCommits` excludes virtual row for conflict-only state
60
+
61
+ **File:** `webview-html.ts:1382-1383`
62
+
63
+ ```javascript
64
+ function getDisplayCommits() {
65
+ const u = state.uncommitted;
66
+ if (!u || (u.staged.length === 0 && u.unstaged.length === 0)) return state.commits;
67
+ // virtual row only added if staged or unstaged files exist
68
+ ```
69
+
70
+ When a merge conflict exists but no staged/unstaged changes (pure merge conflict during `git merge`), `conflicted.length > 0` but `staged.length === 0 && unstaged.length === 0`. The virtual uncommitted row is **not added to the commit list**. The user sees the merge banner but cannot click into the uncommitted detail panel to reach the conflict file list and the "open conflict editor" button.
71
+
72
+ The conflict panel is properly rendered in `renderUncommittedDetail` (checks `u.conflicted`) but is unreachable because `selectCommit('uncommitted')` is only triggered by clicking the virtual row.
73
+
74
+ **Fix:**
75
+
76
+ ```javascript
77
+ if (!u || (u.staged.length === 0 && u.unstaged.length === 0 && (!u.conflicted || u.conflicted.length === 0)))
78
+ return state.commits;
79
+ ```
80
+
81
+ And update the virtual commit message:
82
+ ```javascript
83
+ const totalFiles = u.staged.length + u.unstaged.length + (u.conflicted?.length || 0);
84
+ message: `Uncommitted Changes (${totalFiles} files)${u.conflicted?.length ? ' ⚠ conflicts' : ''}`,
85
+ ```
86
+
87
+ ---
88
+
89
+ ### C3 — Style injection in `ConflictEditor.handleMount` is not idempotent
90
+
91
+ **File:** `conflict-editor.tsx:286-301`
92
+
93
+ ```typescript
94
+ const handleMount: OnMount = (editor, monaco) => {
95
+ // ...
96
+ const styleEl = document.createElement("style");
97
+ styleEl.textContent = `...conflict styles...`;
98
+ editor.getDomNode()?.ownerDocument?.head?.appendChild(styleEl);
99
+ ```
100
+
101
+ `handleMount` fires each time the Monaco editor mounts. If the conflict editor tab is opened, closed, and reopened, the style tag accumulates in `<head>`. This doesn't cause broken behavior (CSS rules are idempotent) but leaks DOM nodes.
102
+
103
+ **Fix:** Add an ID guard:
104
+ ```typescript
105
+ const DOC_STYLE_ID = "conflict-editor-styles";
106
+ if (!editor.getDomNode()?.ownerDocument?.getElementById(DOC_STYLE_ID)) {
107
+ const styleEl = document.createElement("style");
108
+ styleEl.id = DOC_STYLE_ID;
109
+ styleEl.textContent = `...`;
110
+ editor.getDomNode()?.ownerDocument?.head?.appendChild(styleEl);
111
+ }
112
+ ```
113
+
114
+ ---
115
+
116
+ ## High Priority
117
+
118
+ ### H1 — Content widget removal uses an invalid fake object
119
+
120
+ **File:** `conflict-editor.tsx:145-148`
121
+
122
+ ```typescript
123
+ for (const wid of widgetIdsRef.current) {
124
+ const w = editor.getLayoutInfo() && { getId: () => wid } as MonacoType.editor.IContentWidget;
125
+ try { editor.removeContentWidget(w); } catch { /* ignore */ }
126
+ }
127
+ ```
128
+
129
+ `editor.getLayoutInfo()` always returns a truthy object when the editor is alive; `&&` effectively just evaluates to `{ getId: () => wid }`. Monaco's `removeContentWidget` accepts `IContentWidget`, which requires `getDomNode()` and `getPosition()` methods. Monaco's implementation internally looks up widgets by their ID string, so this happens to work — but it is type-unsafe and relies on Monaco's internal implementation detail.
130
+
131
+ The silent `try/catch` hides any future Monaco version breaking this. The real widgets are never stored, so they cannot be properly removed.
132
+
133
+ **Fix:** Store actual widget references:
134
+ ```typescript
135
+ const widgetsRef = useRef<MonacoType.editor.IContentWidget[]>([]);
136
+
137
+ // In refreshConflicts — removal:
138
+ for (const w of widgetsRef.current) {
139
+ editor.removeContentWidget(w);
140
+ }
141
+ widgetsRef.current = [];
142
+
143
+ // When adding:
144
+ editor.addContentWidget(widget);
145
+ widgetsRef.current.push(widget);
146
+ ```
147
+
148
+ ---
149
+
150
+ ### H2 — `ResizeObserver` re-subscribes unnecessarily on `loading`/`error` changes
151
+
152
+ **File:** `conflict-editor.tsx:109-117`
153
+
154
+ ```typescript
155
+ useEffect(() => {
156
+ const el = containerRef.current;
157
+ if (!el) return;
158
+ const ro = new ResizeObserver(...);
159
+ ro.observe(el);
160
+ return () => ro.disconnect();
161
+ }, [loading, error]); // ← problem
162
+ ```
163
+
164
+ The dependency array `[loading, error]` causes the `ResizeObserver` to be torn down and recreated every time loading/error state changes. The intent is to observe after the container renders (post-loading). However, the container `<div ref={containerRef}>` is always mounted (it wraps the Monaco editor), so `containerRef.current` is stable. The correct approach is `[]` (observe once on mount):
165
+
166
+ ```typescript
167
+ }, []); // containerRef is stable; re-observe not needed on state changes
168
+ ```
169
+
170
+ ---
171
+
172
+ ## Medium Priority
173
+
174
+ ### M1 — `acceptConflict` resolves by region ID but re-parses the full file
175
+
176
+ **File:** `conflict-editor.tsx:224-263`
177
+
178
+ ```typescript
179
+ const acceptConflict = useCallback((regionId: number, ...) => {
180
+ const value = model.getValue();
181
+ const regions = parseConflicts(value);
182
+ const region = regions.find((r) => r.id === regionId);
183
+ ```
184
+
185
+ Region IDs are assigned sequentially (0, 1, 2, ...) in `parseConflicts`. After resolving region `id=0`, the model is updated, then `refreshConflicts` re-parses — and the remaining regions are now renumbered starting from 0. If the user clicks "Accept Current" on region 1 (now rendered as `conflict-widget-1`), then resolves region 0 without refreshing, the ID lookup will still find the correct region because `acceptConflict` re-parses the current model state. This is actually correct.
186
+
187
+ However, the `setTimeout(() => refreshConflicts(), 50)` is a fragile way to wait for model stabilization. Monaco's `pushEditOperations` is synchronous; the model is updated before the call returns. `refreshConflicts` can be called directly:
188
+
189
+ ```typescript
190
+ saveFile(model.getValue());
191
+ refreshConflicts(); // synchronous - no timeout needed
192
+ ```
193
+
194
+ ### M2 — Merge banner `action.includes('Abort')` is fragile string matching
195
+
196
+ **File:** `webview-html.ts:927`
197
+
198
+ ```javascript
199
+ const action = btn.dataset.mergeAction;
200
+ if (action.includes('Abort')) {
201
+ ```
202
+
203
+ This works for the current action names (`rebaseAbort`, `mergeAbort`, `cherryPickAbort`) but is fragile if new actions are added. Prefer an explicit set:
204
+
205
+ ```javascript
206
+ const ABORT_ACTIONS = new Set(['rebaseAbort', 'mergeAbort', 'cherryPickAbort']);
207
+ if (ABORT_ACTIONS.has(action)) {
208
+ ```
209
+
210
+ ### M3 — Stash popover and worktree popover do not close each other
211
+
212
+ **File:** `webview-html.ts:763-764, 953-954`
213
+
214
+ Two separate `document.addEventListener('click', ...)` handlers: one closes the worktree popover when clicking outside `.worktree-dropdown`, another closes the stash popover when clicking outside `.stash-dropdown`. But clicking the stash button while the worktree popover is open does NOT close the worktree popover (the stash button is inside `.stash-dropdown`, not `.worktree-dropdown`, so the worktree handler runs and closes it). Actually this works correctly by accident — the worktree click handler fires on every click and checks `closest('.worktree-dropdown')`.
215
+
216
+ Testing shows this is fine, but it's worth adding a mutual close for clarity:
217
+ ```javascript
218
+ btnStash.addEventListener('click', (e) => {
219
+ wtPopover.classList.add('hidden'); // close sibling
220
+ // ...
221
+ });
222
+ ```
223
+
224
+ ---
225
+
226
+ ## Low Priority
227
+
228
+ ### L1 — `conflict-editor.tsx`: `filePath.split("/")` is not cross-platform
229
+
230
+ **File:** `conflict-editor.tsx:306`
231
+
232
+ ```typescript
233
+ const fileName = filePath?.split("/").pop() ?? "unknown";
234
+ ```
235
+
236
+ On Windows paths use `\`. Prefer a regex: `.split(/[\\/]/).pop()`. (Consistent with the extension's existing pattern.)
237
+
238
+ ### L2 — Empty conflict resolution leaves a blank line
239
+
240
+ When "Accept Current" or "Accept Incoming" is used on a conflict where one side is empty (e.g., `<<<<<<< HEAD\n=======\n>>>>>>> branch`), `replacement` = `""` and `text: "" + "\n"` inserts a single blank line. Minor but can leave stray newlines in the file. Low impact.
241
+
242
+ ### L3 — `parseConflicts` doesn't handle diff3-style conflicts
243
+
244
+ `git` can be configured to use `diff3` style which adds a base section between `<<<` and `===`. The parser looks for `=======` but diff3 adds `||||||| base` between `<<<<` and `=====`. In diff3 mode, the parser would put the base content into `currentContent` and miss the real separator. Not broken but results in "Accept Current" keeping the diff3 base section too.
245
+
246
+ ---
247
+
248
+ ## Edge Cases Found
249
+
250
+ 1. **Conflict-only state (C2 above):** No virtual uncommitted row = no access to conflict file list. Blocking.
251
+ 2. **Worktree rebase detection (C1 above):** Hardcoded `.git/rebase-merge` fails for all worktrees.
252
+ 3. **Empty conflict sides:** Resolving an empty-vs-content conflict inserts a blank line (L2).
253
+ 4. **diff3 style conflicts:** Parser misidentifies `||||||| base` as current content (L3).
254
+ 5. **stash popover and worktree popover open simultaneously:** Both can be visible together until a click. Minor UX.
255
+
256
+ ---
257
+
258
+ ## Positive Observations
259
+
260
+ - `assertSafeFilePaths` is applied to `openConflictFile` consistently.
261
+ - `escHtml` is applied to all user-visible git data in the webview (stash messages, branch names, file paths).
262
+ - `assertValidRef` correctly blocks the commit hash passed to `rebase` from the context menu (no injection possible).
263
+ - Stash message parsing correctly handles pipe characters in stash messages via `.slice(2).join("|")`.
264
+ - The 500-file limit in `handleUncommittedStatus` applies globally, preventing unbounded memory use.
265
+ - ResizeObserver cleanup (`return () => ro.disconnect()`) is present.
266
+ - Git stash index is always a safe integer (enforced by both parsing paths).
267
+ - Stash drop/abort operations correctly use confirmation dialogs.
268
+ - `rebaseContinue`/`rebaseAbort`/`mergeAbort` correctly added to `buildGitActionArgs` with no extra args needed (safe).
269
+
270
+ ---
271
+
272
+ ## Recommended Actions
273
+
274
+ 1. **[C1 — BLOCK]** Fix `detectMergeState` to use `git rev-parse --git-dir` before constructing paths.
275
+ 2. **[C2 — BLOCK]** Fix `getDisplayCommits` to include virtual row when `conflicted.length > 0`.
276
+ 3. **[C3 — BLOCK]** Add ID guard to style injection in `handleMount`.
277
+ 4. **[H1]** Store actual widget references instead of fake objects for `removeContentWidget`.
278
+ 5. **[H2]** Change ResizeObserver dependency to `[]`.
279
+ 6. **[M1]** Remove `setTimeout` before `refreshConflicts` call in `acceptConflict`.
280
+ 7. **[M2]** Replace `action.includes('Abort')` with a `Set` check.
281
+
282
+ ---
283
+
284
+ ## Unresolved Questions
285
+
286
+ 1. Is the git graph webview ever shown for a worktree project path? If not, C1 is lower priority, but the worktree feature exists so it should be assumed yes.
287
+ 2. Is diff3 conflict style expected to be supported? If users have `merge.conflictstyle=diff3` in their gitconfig, the parser will silently produce wrong results.
288
+ 3. Should "Accept Both" include a separator between current and incoming content? Some editors insert `--- current ---` / `--- incoming ---` comments when accepting both.
@@ -0,0 +1,117 @@
1
+ # Tester Report — Build & Test Check
2
+ **Date:** 2026-04-15
3
+ **Scope:** git-graph extension + conflict editor component changes
4
+ **Mode:** Diff-aware
5
+
6
+ ---
7
+
8
+ ## Diff-Aware Mode
9
+
10
+ Analyzed 10 changed files:
11
+
12
+ **Changed:**
13
+ - `packages/ext-git-graph/src/types.ts`
14
+ - `packages/ext-git-graph/src/extension.ts`
15
+ - `packages/ext-git-graph/src/webview-html.ts`
16
+ - `src/web/components/editor/conflict-editor.tsx` (NEW)
17
+ - `src/web/stores/tab-store.ts`
18
+ - `src/web/stores/panel-utils.ts`
19
+ - `src/web/components/layout/editor-panel.tsx`
20
+ - `src/web/components/layout/tab-content.tsx`
21
+ - `src/web/components/layout/mobile-nav.tsx`
22
+ - `src/web/components/layout/tab-bar.tsx`
23
+
24
+ **Mapped → Tests (Strategy A/Co-located):**
25
+ - `packages/ext-git-graph/src/extension-parsers.test.ts`
26
+ - `packages/ext-git-graph/src/git-log-parser.test.ts`
27
+ - `packages/ext-git-graph/src/extension-integration.test.ts`
28
+ - `packages/ext-git-graph/src/webview-html.test.ts`
29
+
30
+ **Unmapped (no test files found):**
31
+ - `src/web/components/editor/conflict-editor.tsx`
32
+ - `src/web/stores/tab-store.ts`
33
+ - `src/web/stores/panel-utils.ts`
34
+ - `src/web/components/layout/editor-panel.tsx`
35
+ - `src/web/components/layout/tab-content.tsx`
36
+ - `src/web/components/layout/mobile-nav.tsx`
37
+ - `src/web/components/layout/tab-bar.tsx`
38
+
39
+ ---
40
+
41
+ ## TypeScript Check
42
+
43
+ **Result: PASS** — only 3 known pre-existing errors in:
44
+ - `src/providers/claude-agent-sdk.ts` (TS2322 `"session_migrated"` type mismatch)
45
+ - `src/services/upgrade.service.ts` (TS2532 x2, possibly undefined)
46
+
47
+ **No new TypeScript errors introduced by the changes.**
48
+
49
+ ---
50
+
51
+ ## Build
52
+
53
+ **Result: PASS** — `bun run build:web` succeeded in 851ms, 4314 modules transformed.
54
+
55
+ Warnings (pre-existing, not new):
56
+ - `[INEFFECTIVE_DYNAMIC_IMPORT]` for `keybindings-store.ts` and `settings-tab.tsx` — dynamic imports ineffective due to static imports elsewhere
57
+ - Large chunks (>500kB): `index-D4xwwuhE.js` (544kB), `markdown-renderer` (794kB) — pre-existing
58
+
59
+ Confirms `conflict-editor.tsx` was successfully bundled: `conflict-editor-BIAHaSMw.js` (6.89kB / 2.83kB gzip).
60
+
61
+ ---
62
+
63
+ ## Test Results
64
+
65
+ **Diff-targeted (ext-git-graph):**
66
+ ```
67
+ Ran 62 tests across 4 files — 62 pass, 0 fail [2.43s]
68
+ ```
69
+
70
+ **Full suite:**
71
+ ```
72
+ Ran 1587 tests across 99 files — 1569 pass, 5 fail [204.80s]
73
+ ```
74
+
75
+ ### Failing Tests (all pre-existing, unrelated to changed files)
76
+
77
+ | Test | File | Duration | Root Cause |
78
+ |------|------|----------|------------|
79
+ | Cloud WS Client > queues messages when disconnected... | `tests/integration/cloud-ws-client.test.ts` | 3098ms | Timing/reconnect flakiness |
80
+ | Cloud WS Client > invokes command handler on inbound command | `tests/integration/cloud-ws-client.test.ts` | 3071ms | Timing/reconnect flakiness |
81
+ | Logs endpoint > GET /api/logs/recent returns last log lines | `tests/integration/api/server-health-logs.test.ts` | 1ms | Log file path issue |
82
+ | Logs endpoint > GET /api/logs/recent redacts sensitive data | `tests/integration/api/server-health-logs.test.ts` | 0.25ms | Log file path issue |
83
+ | Logs endpoint > GET /api/logs/recent returns empty when no log file | `tests/integration/api/server-health-logs.test.ts` | 0.3ms | Log file path issue |
84
+
85
+ All 5 failures exist on `main` before these changes — none of those test files were modified by the current changeset.
86
+
87
+ ---
88
+
89
+ ## Coverage Gaps (Unmapped Files)
90
+
91
+ [!] No tests found for `src/web/components/editor/conflict-editor.tsx` — NEW component with no test coverage. Consider adding tests for:
92
+ - Conflict block parsing and rendering (ours/theirs/base sections)
93
+ - "Accept ours / Accept theirs" action handlers
94
+ - Keyboard navigation between conflict markers
95
+
96
+ [!] No tests found for `src/web/stores/tab-store.ts` — critical state management with no unit tests. Consider:
97
+ - Tab open/close/switch transitions
98
+ - Conflict editor tab type handling
99
+
100
+ [!] No tests found for `src/web/stores/panel-utils.ts`, `editor-panel.tsx`, `tab-content.tsx`, `mobile-nav.tsx`, `tab-bar.tsx` — consistent with project pattern (no frontend component tests exist).
101
+
102
+ ---
103
+
104
+ ## Summary
105
+
106
+ | Check | Result |
107
+ |-------|--------|
108
+ | TypeScript (`bunx tsc --noEmit`) | PASS — no new errors |
109
+ | Vite build (`bun run build:web`) | PASS |
110
+ | ext-git-graph unit tests | 62/62 pass |
111
+ | Full test suite | 1569/1574 pass (5 pre-existing failures) |
112
+
113
+ ---
114
+
115
+ **Unresolved Questions:**
116
+ 1. The 2 `cloud-ws-client` failures are timing-sensitive — are they known flaky tests scheduled for fix?
117
+ 2. The 3 `server-health-logs` failures suggest log file path misconfiguration in test env — is this being tracked?
@@ -0,0 +1,205 @@
1
+ # Code Review: Extension Error Reporting & Logging
2
+
3
+ ## Scope
4
+ - **Files**: 7 (extension.service.ts, ws/extensions.ts, extension-store.ts, use-extension-ws.ts, extension-webview.tsx, extension-host-worker.ts, ext-git-graph/extension.ts)
5
+ - **LOC changed**: ~270 additions across error tracking, toast notifications, breadcrumb logs, stash/conflict features
6
+ - **Focus**: Error propagation, type safety, memory, race conditions, toast spam
7
+
8
+ ## Overall Assessment
9
+
10
+ Solid improvement to extension debugging. The silent failure path is now surfaced end-to-end: activation errors stored server-side, sent to clients on connect and broadcast, displayed in webview UI with retry. Breadcrumb logs added at each layer. A few issues need attention.
11
+
12
+ ---
13
+
14
+ ## Critical Issues
15
+
16
+ None found.
17
+
18
+ ---
19
+
20
+ ## High Priority
21
+
22
+ ### H1. Type bypass: `activationErrors` piggybacked outside `ExtServerMsg` union
23
+
24
+ **Files**: `extensions.ts:78-79`, `use-extension-ws.ts:43-44`, `extension.service.ts:269-272`
25
+
26
+ The `activationErrors` field is added to `contributions:update` messages via `Record<string, unknown>` or `(msg as any).activationErrors`, bypassing the `ExtServerMsg` discriminated union type. The type definition at `extension-messages.ts:54` does not include this field.
27
+
28
+ **Impact**: Any future refactor touching `ExtServerMsg` won't catch this field. TypeScript provides zero safety on the consumer side. If the field name changes server-side, the client silently receives nothing.
29
+
30
+ **Fix**: Extend the union member:
31
+ ```ts
32
+ // extension-messages.ts
33
+ | { type: "contributions:update"; contributions: ExtensionContributes; activationErrors?: Record<string, string> }
34
+ ```
35
+ Then remove all `(msg as any).activationErrors` casts and `Record<string, unknown>` intermediate types.
36
+
37
+ ### H2. Toast spam on every `contributions:update` broadcast
38
+
39
+ **File**: `use-extension-ws.ts:43-49`
40
+
41
+ Every `contributions:update` with `activationErrors` fires `toast.error()` for each error entry. This message is broadcast on every `activate()` AND `deactivate()` call (both call `broadcastContributions()`). If 3 extensions fail on startup and then any extension activates/deactivates later, all 3 error toasts fire again.
42
+
43
+ **Impact**: Users see repeated error toasts for errors they already acknowledged. With N failing extensions and M subsequent operations, toast count = N * M.
44
+
45
+ **Fix**: Track which errors have already been toasted (e.g., compare previous `activationErrors` keys before showing new toasts):
46
+ ```ts
47
+ case "contributions:update":
48
+ store.setContributions(msg.contributions);
49
+ if (msg.activationErrors) {
50
+ const prev = store.activationErrors;
51
+ const errors = msg.activationErrors;
52
+ store.setActivationErrors(errors);
53
+ // Only toast NEW errors
54
+ for (const [extId, error] of Object.entries(errors)) {
55
+ if (!prev[extId]) {
56
+ toast.error(`Extension "${extId}" failed to activate: ${error}`);
57
+ }
58
+ }
59
+ }
60
+ break;
61
+ ```
62
+
63
+ ### H3. `activationErrors` Map never cleared on `terminateWorker()` / `shutdown()`
64
+
65
+ **File**: `extension.service.ts:55-63`
66
+
67
+ `terminateWorker()` clears `activatedIds`, `extensionPaths`, `bundledIds` but not `activationErrors`. After a shutdown + restart cycle, stale errors from the previous session persist and get broadcast to new clients.
68
+
69
+ **Fix**: Add `this.activationErrors.clear()` inside `terminateWorker()`.
70
+
71
+ ---
72
+
73
+ ## Medium Priority
74
+
75
+ ### M1. Retry loop fires commands indefinitely until panel appears
76
+
77
+ **File**: `extension-webview.tsx:86-94`
78
+
79
+ The `setInterval` fires `ext:command:execute` every 2 seconds with no upper bound (except the 10s timeout that just shows an error message, but does NOT stop the interval). The cleanup function only runs on unmount or dependency change (`[panel, viewType, projectName]`).
80
+
81
+ If the extension is broken (e.g., activation failed), this dispatches a command every 2s forever while the tab is open.
82
+
83
+ **Impact**: Wasted network/CPU; if the command triggers server-side side effects (e.g., webview creation), could cause resource leaks.
84
+
85
+ **Fix**: Cap retries (e.g., 5 attempts) or clear the interval when `timedOut` becomes true:
86
+ ```ts
87
+ let attempts = 0;
88
+ const retryTimer = setInterval(() => {
89
+ if (!cancelled && attempts++ < 5) attempt();
90
+ else clearInterval(retryTimer);
91
+ }, 2_000);
92
+ ```
93
+
94
+ ### M2. `activationError` matching in extension-webview.tsx is fragile
95
+
96
+ **File**: `extension-webview.tsx:122-128`
97
+
98
+ ```ts
99
+ if (extId.includes(viewType) || viewType.includes(extId.replace(/^ext-/, "")))
100
+ ```
101
+
102
+ This substring matching can produce false positives. Example: viewType `"graph"` would match extId `"ext-git-graph"` and also hypothetically `"ext-graph-viz"`, `"ext-photography"`. Also, an extId like `"ext-editor"` stripped to `"editor"` would match a viewType of `"editor-settings"`.
103
+
104
+ **Fix**: Use exact matching or a registry-based lookup:
105
+ ```ts
106
+ const extensionId = metadata?.extensionId as string | undefined;
107
+ const activationError = useExtensionStore((s) =>
108
+ extensionId ? s.activationErrors[extensionId] : undefined
109
+ );
110
+ ```
111
+
112
+ ### M3. `detectMergeState` path construction not Windows-safe
113
+
114
+ **File**: `ext-git-graph/extension.ts:543`
115
+
116
+ ```ts
117
+ if (!gitDir.startsWith("/")) gitDir = `${projectPath}/${gitDir}`;
118
+ ```
119
+
120
+ On Windows, git returns paths with backslashes or drive letters (e.g., `C:\...`). The `startsWith("/")` check fails. String concatenation with `/` produces mixed separators.
121
+
122
+ Per project memory (`feedback_cross_platform_paths.md`): file/path features must work on Windows too.
123
+
124
+ **Fix**: Use `path.resolve()` or `path.isAbsolute()`:
125
+ ```ts
126
+ const path = require("path");
127
+ if (!path.isAbsolute(gitDir)) gitDir = path.resolve(projectPath, gitDir);
128
+ ```
129
+
130
+ ### M4. `getActivationErrors()` returns mutable reference
131
+
132
+ **File**: `extension.service.ts:263`
133
+
134
+ ```ts
135
+ getActivationErrors(): Map<string, string> { return this.activationErrors; }
136
+ ```
137
+
138
+ Callers (ws/extensions.ts) get a direct reference to the internal Map. If any consumer mutates it, the service state is corrupted silently.
139
+
140
+ **Fix**: Return a snapshot:
141
+ ```ts
142
+ getActivationErrors(): Map<string, string> { return new Map(this.activationErrors); }
143
+ ```
144
+
145
+ ---
146
+
147
+ ## Low Priority
148
+
149
+ ### L1. `broadcastExtMsg` type mismatch with `Record<string, unknown>`
150
+
151
+ **File**: `extensions.ts:78-80`
152
+
153
+ `readyMsg` is typed as `Record<string, unknown>` but passed to `ws.send(JSON.stringify(readyMsg))` directly, bypassing the `broadcastExtMsg(msg: ExtServerMsg)` type check. This is consistent with the H1 issue -- fixing H1 resolves this.
154
+
155
+ ### L2. Error messages in command:execute notifications could leak internal paths
156
+
157
+ **File**: `extensions.ts:98, 112-116`
158
+
159
+ `result?.error` and `e.message` are sent directly to browser clients. These can contain stack traces with server file paths (e.g., `/Users/hienlh/Projects/ppm/...`).
160
+
161
+ **Fix**: Truncate or sanitize error messages before broadcasting:
162
+ ```ts
163
+ const safeMsg = (msg: string) => msg.split('\n')[0].slice(0, 200);
164
+ ```
165
+
166
+ ### L3. `broadcastExtMsg` used for command errors sends to ALL clients
167
+
168
+ **File**: `extensions.ts:94-116`
169
+
170
+ When one client's command fails, the error notification is broadcast to all connected clients via `broadcastExtMsg`. Only the requesting client should see the error.
171
+
172
+ **Fix**: Send error only to the requesting socket:
173
+ ```ts
174
+ ws.send(JSON.stringify({ type: "notification", ... }));
175
+ ```
176
+
177
+ ---
178
+
179
+ ## Positive Observations
180
+
181
+ 1. **Breadcrumb logging** at each layer (ExtService, ExtHost, ExtWS, extension) creates clear trail for debugging
182
+ 2. **Activation error storage** with Map allows per-extension tracking and clearing on successful retry
183
+ 3. **Error display in webview** with retry button is good UX -- users get actionable feedback
184
+ 4. **Existing security**: `assertSafeFilePaths` properly validates all user-supplied paths in git-graph
185
+ 5. **Timeout handling** in activation (10s in worker) and webview loading (10s in UI) prevents indefinite hangs
186
+ 6. **Stash and conflict detection** implementations are clean and well-structured
187
+
188
+ ---
189
+
190
+ ## Recommended Actions (Priority Order)
191
+
192
+ 1. **[H1]** Add `activationErrors?` to `ExtServerMsg` type union, remove `as any` casts
193
+ 2. **[H2]** Deduplicate toasts by tracking previously shown errors
194
+ 3. **[H3]** Clear `activationErrors` in `terminateWorker()`
195
+ 4. **[M1]** Cap retry attempts in extension-webview reload loop
196
+ 5. **[M2]** Use exact extension ID matching instead of substring
197
+ 6. **[M3]** Fix Windows path handling in `detectMergeState`
198
+ 7. **[M4]** Return Map copy from `getActivationErrors()`
199
+
200
+ ---
201
+
202
+ ## Unresolved Questions
203
+
204
+ - Is there an intentional reason `activationErrors` is not part of the `ExtServerMsg` type? If so, document the rationale.
205
+ - Should `activationErrors` be cleared when an extension is uninstalled/removed? Currently `remove()` calls `deactivate()` but doesn't touch `activationErrors`.