@hienlh/ppm 0.13.2 → 0.13.4

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 (103) hide show
  1. package/CHANGELOG.md +31 -0
  2. package/assets/skills/ppm/SKILL.md +1 -1
  3. package/assets/skills/ppm/references/http-api.md +1 -1
  4. package/dist/web/assets/{ai-settings-section-QE6nBNgN.js → ai-settings-section-DeW4WN43.js} +1 -1
  5. package/dist/web/assets/{api-settings-DAk7D-NP.js → api-settings-t7Leca7J.js} +1 -1
  6. package/dist/web/assets/architecture-PBZL5I3N-Dy3PgD6O.js +1 -0
  7. package/dist/web/assets/{audio-preview--hRMnXRZ.js → audio-preview-BSAe2WQB.js} +1 -1
  8. package/dist/web/assets/chat-tab-UFEFOnpl.js +12 -0
  9. package/dist/web/assets/code-editor-BJ1tSNWA.js +8 -0
  10. package/dist/web/assets/{conflict-editor-Dlo25nmt.js → conflict-editor-CrgrMZ2F.js} +1 -1
  11. package/dist/web/assets/{csv-preview-HMSavgBb.js → csv-preview-C9qGhDlb.js} +1 -1
  12. package/dist/web/assets/{database-viewer-DcBl6OkV.js → database-viewer-e_NAkIL_.js} +2 -2
  13. package/dist/web/assets/diff-viewer-C2eOczTs.js +4 -0
  14. package/dist/web/assets/{esm-K1XIK4vc.js → esm-B3je8j5P.js} +1 -1
  15. package/dist/web/assets/{extension-webview-D7bGVSEd.js → extension-webview-B95nOfj-.js} +2 -2
  16. package/dist/web/assets/{file-store-BrbCNyLm.js → file-store-BgZggznw.js} +1 -1
  17. package/dist/web/assets/gitGraph-HDMCJU4V-Bu1SIFFq.js +1 -0
  18. package/dist/web/assets/{image-preview-CfkqnhXJ.js → image-preview-DAuPOzYl.js} +1 -1
  19. package/dist/web/assets/index-DJOjXTcq.js +27 -0
  20. package/dist/web/assets/index-DSOP0R0s.css +2 -0
  21. package/dist/web/assets/info-3K5VOQVL-DzfAxmVd.js +1 -0
  22. package/dist/web/assets/{input-Dk49gO8E.js → input-bGJExpJZ.js} +1 -1
  23. package/dist/web/assets/keybindings-store-V12kZZHO.js +1 -0
  24. package/dist/web/assets/{markdown-renderer-DyAm7zuA.js → markdown-renderer-DwINRWo4.js} +3 -3
  25. package/dist/web/assets/packet-RMMSAZCW-DpzHf4xp.js +1 -0
  26. package/dist/web/assets/{pdf-preview-CZPcuy5c.js → pdf-preview-CqoQE09t.js} +1 -1
  27. package/dist/web/assets/pie-UPGHQEXC-BpzFCKJ8.js +1 -0
  28. package/dist/web/assets/{port-forwarding-tab-3RNozlZ5.js → port-forwarding-tab-De7qxkjp.js} +1 -1
  29. package/dist/web/assets/{postgres-viewer-CXJv4TXc.js → postgres-viewer-Dd6rLb8b.js} +3 -3
  30. package/dist/web/assets/radar-KQ55EAFF-DAxWKxM4.js +1 -0
  31. package/dist/web/assets/{scroll-area-BEllam7_.js → scroll-area-D0EQpAH2.js} +1 -1
  32. package/dist/web/assets/{settings-store-BLLR7ed8.js → settings-store-CdcSAgEZ.js} +2 -2
  33. package/dist/web/assets/settings-tab-BdTEumwU.js +1 -0
  34. package/dist/web/assets/{sql-query-editor-CVAnRFbi.js → sql-query-editor-vpD0I0KG.js} +1 -1
  35. package/dist/web/assets/sqlite-viewer-Ccz2crvN.js +1 -0
  36. package/dist/web/assets/{tab-store-B3M9hjho.js → tab-store-Jvy1eZGM.js} +1 -1
  37. package/dist/web/assets/terminal-tab-D7u7wsyb.js +1 -0
  38. package/dist/web/assets/treemap-KZPCXAKY-D6dgXbAe.js +1 -0
  39. package/dist/web/assets/{use-blob-url-e9uTXjv5.js → use-blob-url-BgxxT-n_.js} +1 -1
  40. package/dist/web/assets/{use-monaco-theme-BkZDwoVd.js → use-monaco-theme-dtPsv6sh.js} +1 -1
  41. package/dist/web/assets/{vendor-mermaid-Dx86tuVP.js → vendor-mermaid-DCxaaPi4.js} +2 -2
  42. package/dist/web/assets/{video-preview-Dfz71RGb.js → video-preview-BSDzqlzk.js} +1 -1
  43. package/dist/web/index.html +17 -19
  44. package/dist/web/sw.js +1 -1
  45. package/docs/codebase-summary.md +2 -0
  46. package/docs/journals/2026-04-22-compare-files-feature-ship.md +53 -0
  47. package/docs/project-changelog.md +9 -1
  48. package/package.json +1 -1
  49. package/src/providers/claude-agent-sdk.ts +5 -2
  50. package/src/server/routes/chat.ts +10 -1
  51. package/src/server/ws/chat.ts +29 -2
  52. package/src/services/file-filter.service.ts +17 -4
  53. package/src/services/file-list-index.service.ts +7 -3
  54. package/src/types/chat.ts +1 -1
  55. package/src/types/project.ts +2 -0
  56. package/src/web/app.tsx +4 -0
  57. package/src/web/components/chat/message-list.tsx +6 -5
  58. package/src/web/components/editor/compare-picker.tsx +245 -0
  59. package/src/web/components/explorer/file-tree.tsx +42 -1
  60. package/src/web/components/layout/command-palette.tsx +66 -13
  61. package/src/web/components/layout/draggable-tab.tsx +13 -5
  62. package/src/web/components/layout/tab-bar.tsx +101 -27
  63. package/src/web/hooks/use-chat.ts +6 -0
  64. package/src/web/hooks/use-global-keybindings.ts +20 -0
  65. package/src/web/lib/open-compare-tab.ts +76 -0
  66. package/src/web/lib/score-file-search.ts +41 -21
  67. package/src/web/stores/compare-store.ts +57 -0
  68. package/src/web/stores/keybindings-store.ts +1 -0
  69. package/dist/web/assets/architecture-PBZL5I3N-DvZbltvY.js +0 -1
  70. package/dist/web/assets/chat-tab-4kL3DNxf.js +0 -12
  71. package/dist/web/assets/code-editor-Caq5_BaF.js +0 -8
  72. package/dist/web/assets/columns-2-4fQcE4PF.js +0 -1
  73. package/dist/web/assets/diff-viewer-CCzPq1o-.js +0 -4
  74. package/dist/web/assets/extension-store-3yZYn07W.js +0 -1
  75. package/dist/web/assets/gitGraph-HDMCJU4V-BxhdxFgj.js +0 -1
  76. package/dist/web/assets/index-BGFG66Gh.js +0 -27
  77. package/dist/web/assets/index-Bce0weeW.css +0 -2
  78. package/dist/web/assets/info-3K5VOQVL-BwAZ2zd8.js +0 -1
  79. package/dist/web/assets/keybindings-store-B-zET-0o.js +0 -1
  80. package/dist/web/assets/keybindings-store-DaBV6qhz.js +0 -1
  81. package/dist/web/assets/packet-RMMSAZCW-tx2n5Qry.js +0 -1
  82. package/dist/web/assets/pie-UPGHQEXC-D6S2MqVT.js +0 -1
  83. package/dist/web/assets/radar-KQ55EAFF-BviZcL-b.js +0 -1
  84. package/dist/web/assets/settings-tab-Cnav4g2u.js +0 -1
  85. package/dist/web/assets/sqlite-viewer-C8WUEFhA.js +0 -1
  86. package/dist/web/assets/terminal-tab-CaEsMxp8.js +0 -1
  87. package/dist/web/assets/treemap-KZPCXAKY-CM54VdaB.js +0 -1
  88. /package/dist/web/assets/{api-client-Dvzcc_EO.js → api-client-r4nyVy7H.js} +0 -0
  89. /package/dist/web/assets/{csv-parser--2WJNgS7.js → csv-parser-DxVplKKB.js} +0 -0
  90. /package/dist/web/assets/{database-D4DIhgi-.js → database-DCT0OjgQ.js} +0 -0
  91. /package/dist/web/assets/{dist-im4ynINo.js → dist-BqoEabX7.js} +0 -0
  92. /package/dist/web/assets/{file-exclamation-point-BwzaQ50n.js → file-exclamation-point-Baz81y5z.js} +0 -0
  93. /package/dist/web/assets/{katex-CKoArbIw.js → katex-bpagxk3Z.js} +0 -0
  94. /package/dist/web/assets/{lib-DQHnkzGy.js → lib-BqkcKGFq.js} +0 -0
  95. /package/dist/web/assets/{react-GqWghJ-L.js → react-BkWDCPD7.js} +0 -0
  96. /package/dist/web/assets/{refresh-cw-LlbZDJpO.js → refresh-cw-CSFrDtiu.js} +0 -0
  97. /package/dist/web/assets/{sql-completion-provider-C3cq9j99.js → sql-completion-provider-EzHOQLfo.js} +0 -0
  98. /package/dist/web/assets/{table-Dq575bPF.js → table-DbSviOmw.js} +0 -0
  99. /package/dist/web/assets/{text-wrap-Cn6BNQfq.js → text-wrap-DzvCTq_i.js} +0 -0
  100. /package/dist/web/assets/{trash-2-CJYoLw7Q.js → trash-2-BgDIBl6f.js} +0 -0
  101. /package/dist/web/assets/{utils-CTg5uAYR.js → utils-ChWX7pZv.js} +0 -0
  102. /package/dist/web/assets/{vendor-xterm-CU2c3f0A.js → vendor-xterm-D7SePDJp.js} +0 -0
  103. /package/dist/web/assets/{x-DlFGzN8d.js → x-BtqbfkR7.js} +0 -0
@@ -505,6 +505,9 @@ export function useChat(sessionId: string | null, providerId = "claude", project
505
505
  setPhase(p);
506
506
  phaseRef.current = p;
507
507
  setConnectingElapsed(p === "connecting" ? ((data as any).elapsed ?? 0) : 0);
508
+ // Safety: idle phase means no turn running — ensure compact indicator does not linger.
509
+ // BE should broadcast compact_status=done too, but this is a belt-and-braces clear.
510
+ if (p === "idle") setCompactStatus(null);
508
511
  return;
509
512
  }
510
513
 
@@ -523,6 +526,9 @@ export function useChat(sessionId: string | null, providerId = "claude", project
523
526
  input: state.pendingApproval.input,
524
527
  });
525
528
  }
529
+ // Sync compact indicator from authoritative server state (covers reconnect).
530
+ // state.compactStatus is "compacting" | null — treat undefined as null for back-compat.
531
+ setCompactStatus(state.compactStatus === "compacting" ? "compacting" : null);
526
532
  // If idle, refetch history (completed turns) and hide overlay
527
533
  if (p === "idle") {
528
534
  refetchRef.current?.();
@@ -4,6 +4,8 @@ import { useSettingsStore } from "@/stores/settings-store";
4
4
  import { useProjectStore } from "@/stores/project-store";
5
5
  import { useKeybindingsStore, parseCombo, eventMatchesCombo } from "@/stores/keybindings-store";
6
6
  import { useExtensionStore } from "@/stores/extension-store";
7
+ import { useCompareStore } from "@/stores/compare-store";
8
+ import { basename } from "@/lib/utils";
7
9
 
8
10
  /** Dispatch this event to open the command palette from anywhere, optionally with initial query */
9
11
  export function openCommandPalette(initialQuery?: string) {
@@ -156,6 +158,24 @@ export function useGlobalKeybindings() {
156
158
  return;
157
159
  }
158
160
 
161
+ // Compare Files — seed A from active editor tab if applicable, then open picker
162
+ if (match(e, "compare-files")) {
163
+ e.preventDefault();
164
+ const { activeTabId, tabs } = useTabStore.getState();
165
+ const active = tabs.find((t) => t.id === activeTabId);
166
+ const meta = active?.metadata as { filePath?: string; projectName?: string; unsavedContent?: string } | undefined;
167
+ if (active?.type === "editor" && meta?.filePath && meta?.projectName) {
168
+ useCompareStore.getState().setSelection({
169
+ filePath: meta.filePath,
170
+ projectName: meta.projectName,
171
+ dirtyContent: meta.unsavedContent,
172
+ label: basename(meta.filePath),
173
+ });
174
+ }
175
+ window.dispatchEvent(new CustomEvent("open-compare-picker"));
176
+ return;
177
+ }
178
+
159
179
  // Open search (sidebar)
160
180
  if (match(e, "open-search")) {
161
181
  e.preventDefault();
@@ -0,0 +1,76 @@
1
+ import { api, projectUrl } from "@/lib/api-client";
2
+ import { useTabStore } from "@/stores/tab-store";
3
+ import { basename } from "@/lib/utils";
4
+
5
+ /** One side of a compare — path + optional in-memory dirty buffer. */
6
+ export interface CompareSide {
7
+ path: string;
8
+ dirtyContent?: string;
9
+ }
10
+
11
+ /**
12
+ * Open a `git-diff` tab comparing two files.
13
+ *
14
+ * Routing:
15
+ * - If either side has `dirtyContent` → fetch clean side via `/files/read`
16
+ * and pass `original`+`modified` inline (DiffViewer's inline mode).
17
+ * - Else → pass `file1`+`file2` metadata (DiffViewer fetches `/files/compare`).
18
+ *
19
+ * Returns the new tab id.
20
+ */
21
+ export async function openCompareTab(
22
+ a: CompareSide,
23
+ b: CompareSide,
24
+ projectName: string,
25
+ ): Promise<string> {
26
+ const title = `${basename(a.path)} ↔ ${basename(b.path)}`;
27
+ const aDirty = a.dirtyContent !== undefined;
28
+ const bDirty = b.dirtyContent !== undefined;
29
+
30
+ let metadata: Record<string, unknown>;
31
+
32
+ if (aDirty || bDirty) {
33
+ const [original, modified] = await Promise.all([
34
+ resolveSideContent(a, projectName),
35
+ resolveSideContent(b, projectName),
36
+ ]);
37
+ // Inline mode — DiffViewer uses `original`/`modified` when present
38
+ // (see diff-viewer.tsx:36 `isInline` check).
39
+ metadata = {
40
+ projectName,
41
+ original,
42
+ modified,
43
+ // Keep paths around for future needs (copy path, re-open source, etc.).
44
+ file1: a.path,
45
+ file2: b.path,
46
+ };
47
+ } else {
48
+ metadata = {
49
+ projectName,
50
+ file1: a.path,
51
+ file2: b.path,
52
+ };
53
+ }
54
+
55
+ const id = useTabStore.getState().openTab({
56
+ type: "git-diff",
57
+ title,
58
+ projectId: projectName,
59
+ metadata,
60
+ closable: true,
61
+ });
62
+ return id;
63
+ }
64
+
65
+ async function resolveSideContent(side: CompareSide, projectName: string): Promise<string> {
66
+ if (side.dirtyContent !== undefined) return side.dirtyContent;
67
+ try {
68
+ const { content } = await api.get<{ content: string }>(
69
+ `${projectUrl(projectName)}/files/read?path=${encodeURIComponent(side.path)}`,
70
+ );
71
+ return content;
72
+ } catch (err) {
73
+ const reason = err instanceof Error ? err.message : String(err);
74
+ throw new Error(`Failed to read "${side.path}": ${reason}`);
75
+ }
76
+ }
@@ -6,6 +6,10 @@
6
6
  * > path contains(3) > fuzzy filename(4) > fuzzy path(5)
7
7
  *
8
8
  * Tie-breakers: shorter filename, fewer path segments.
9
+ *
10
+ * Hot-path note: callers pass PRE-LOWERCASED strings to avoid repeated
11
+ * allocations per keystroke. Use `scoreFileSearch` (convenience wrapper)
12
+ * for ad-hoc calls; use `scoreFileSearchFast` for the inner loop.
9
13
  */
10
14
 
11
15
  export interface FileSearchScore {
@@ -20,7 +24,7 @@ export interface FileSearchScore {
20
24
  }
21
25
 
22
26
  /** Extract filename from a path */
23
- function getFilename(path: string): string {
27
+ export function getFilename(path: string): string {
24
28
  const i = Math.max(path.lastIndexOf("/"), path.lastIndexOf("\\"));
25
29
  return i >= 0 ? path.slice(i + 1) : path;
26
30
  }
@@ -43,42 +47,58 @@ function fuzzyGap(query: string, text: string): number {
43
47
  return gap;
44
48
  }
45
49
 
46
- export function scoreFileSearch(
47
- query: string,
48
- label: string,
49
- path: string,
50
+ /**
51
+ * Fast scoring — requires pre-lowercased inputs. Use for tight loops.
52
+ * All string params MUST already be lowercase.
53
+ */
54
+ export function scoreFileSearchFast(
55
+ qLower: string,
56
+ filenameLower: string,
57
+ pathLower: string,
58
+ labelLen: number,
59
+ depth: number,
50
60
  ): FileSearchScore | null {
51
- const q = query.toLowerCase();
52
- const nameLower = label.toLowerCase();
53
- const pathLower = path.toLowerCase();
54
- const filename = getFilename(pathLower);
55
- const depth = path.split("/").length;
56
-
57
61
  // Tier 0: exact filename match
58
- if (filename === q) return { tier: 0, offset: 0, nameLen: label.length, depth };
62
+ if (filenameLower === qLower) return { tier: 0, offset: 0, nameLen: labelLen, depth };
59
63
 
60
64
  // Tier 1: filename starts with query
61
- if (filename.startsWith(q)) return { tier: 1, offset: 0, nameLen: label.length, depth };
65
+ if (filenameLower.startsWith(qLower)) return { tier: 1, offset: 0, nameLen: labelLen, depth };
62
66
 
63
67
  // Tier 2: filename contains query as substring
64
- const fnIdx = filename.indexOf(q);
65
- if (fnIdx >= 0) return { tier: 2, offset: fnIdx, nameLen: label.length, depth };
68
+ const fnIdx = filenameLower.indexOf(qLower);
69
+ if (fnIdx >= 0) return { tier: 2, offset: fnIdx, nameLen: labelLen, depth };
66
70
 
67
71
  // Tier 3: full path contains query as substring
68
- const pathIdx = pathLower.indexOf(q);
69
- if (pathIdx >= 0) return { tier: 3, offset: pathIdx, nameLen: label.length, depth };
72
+ const pathIdx = pathLower.indexOf(qLower);
73
+ if (pathIdx >= 0) return { tier: 3, offset: pathIdx, nameLen: labelLen, depth };
70
74
 
71
75
  // Tier 4: fuzzy match on filename
72
- const fnGap = fuzzyGap(q, filename);
73
- if (fnGap >= 0) return { tier: 4, offset: fnGap, nameLen: label.length, depth };
76
+ const fnGap = fuzzyGap(qLower, filenameLower);
77
+ if (fnGap >= 0) return { tier: 4, offset: fnGap, nameLen: labelLen, depth };
74
78
 
75
79
  // Tier 5: fuzzy match on full path
76
- const pathGap = fuzzyGap(q, pathLower);
77
- if (pathGap >= 0) return { tier: 5, offset: pathGap, nameLen: label.length, depth };
80
+ const pathGap = fuzzyGap(qLower, pathLower);
81
+ if (pathGap >= 0) return { tier: 5, offset: pathGap, nameLen: labelLen, depth };
78
82
 
79
83
  return null;
80
84
  }
81
85
 
86
+ /** Convenience wrapper — lowers inputs on the fly. Use for ad-hoc calls. */
87
+ export function scoreFileSearch(
88
+ query: string,
89
+ label: string,
90
+ path: string,
91
+ ): FileSearchScore | null {
92
+ const pathLower = path.toLowerCase();
93
+ return scoreFileSearchFast(
94
+ query.toLowerCase(),
95
+ getFilename(pathLower),
96
+ pathLower,
97
+ label.length,
98
+ path.split("/").length,
99
+ );
100
+ }
101
+
82
102
  /** Compare two scores — for Array.sort (ascending = best first) */
83
103
  export function compareScores(a: FileSearchScore, b: FileSearchScore): number {
84
104
  return (
@@ -0,0 +1,57 @@
1
+ import { create } from "zustand";
2
+ import { persist } from "zustand/middleware";
3
+ import { useProjectStore } from "@/stores/project-store";
4
+
5
+ /** Selection captured when user picks a file "for compare". */
6
+ export interface CompareSelection {
7
+ filePath: string;
8
+ projectName: string;
9
+ /** Captured snapshot of dirty editor buffer — undefined if file was clean. */
10
+ dirtyContent?: string;
11
+ /** Display name (basename) for menu/dialog UI. */
12
+ label: string;
13
+ }
14
+
15
+ interface CompareStore {
16
+ selection: CompareSelection | null;
17
+ setSelection: (sel: CompareSelection) => void;
18
+ clearSelection: () => void;
19
+ }
20
+
21
+ /** Avoid persisting huge dirty buffers (>500KB) to keep localStorage fast. */
22
+ const MAX_DIRTY_PERSIST_BYTES = 500_000;
23
+
24
+ export const useCompareStore = create<CompareStore>()(
25
+ persist(
26
+ (set) => ({
27
+ selection: null,
28
+ setSelection: (sel) => set({ selection: sel }),
29
+ clearSelection: () => set({ selection: null }),
30
+ }),
31
+ {
32
+ name: "ppm:compare-selection",
33
+ // Strip oversized dirtyContent before persisting — keep the path so user
34
+ // can still compare (content will be re-fetched from disk).
35
+ partialize: (s) => {
36
+ if (!s.selection) return { selection: null };
37
+ const sel = s.selection;
38
+ if (sel.dirtyContent && sel.dirtyContent.length > MAX_DIRTY_PERSIST_BYTES) {
39
+ const { dirtyContent: _, ...rest } = sel;
40
+ return { selection: rest };
41
+ }
42
+ return { selection: sel };
43
+ },
44
+ },
45
+ ),
46
+ );
47
+
48
+ // Auto-clear selection when the user switches active project.
49
+ // Tracked in module scope to avoid clearing on the initial load hydration.
50
+ let lastActiveProject: string | null = null;
51
+ useProjectStore.subscribe((state) => {
52
+ const now = state.activeProject?.name ?? null;
53
+ if (lastActiveProject !== null && lastActiveProject !== now) {
54
+ useCompareStore.getState().clearSelection();
55
+ }
56
+ lastActiveProject = now;
57
+ });
@@ -37,6 +37,7 @@ export const KEY_ACTIONS: KeyAction[] = [
37
37
  { id: "open-git-status", label: "Git Status (sidebar)", category: "tabs", defaultKey: "Mod+Shift+E" },
38
38
  { id: "open-search", label: "Search Files (sidebar)", category: "tabs", defaultKey: "Mod+Shift+F" },
39
39
  { id: "voice-input", label: "Voice Input", category: "general", defaultKey: "Mod+Shift+V", note: "Toggle speech-to-text in chat" },
40
+ { id: "compare-files", label: "Compare Files...", category: "general", defaultKey: "Mod+Alt+D", note: "Open file-compare picker (seeds active file as A)" },
40
41
  // Projects — Mod+1..9
41
42
  ...Array.from({ length: 9 }, (_, i) => ({
42
43
  id: `switch-project-${i + 1}`,
@@ -1 +0,0 @@
1
- import{W as e}from"./vendor-mermaid-Dx86tuVP.js";export{e as createArchitectureServices};