@hienlh/ppm 0.13.3 → 0.13.5

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 (100) hide show
  1. package/CHANGELOG.md +28 -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-R7cq1uhJ.js → audio-preview-BdRw2cYi.js} +1 -1
  8. package/dist/web/assets/chat-tab-C2NBEXEX.js +12 -0
  9. package/dist/web/assets/code-editor-BhmUC3pD.js +8 -0
  10. package/dist/web/assets/{conflict-editor-dzofjxab.js → conflict-editor-Br_CSQdA.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-5Uf8Rrls.js → database-viewer-CbIMjroK.js} +2 -2
  13. package/dist/web/assets/diff-viewer-oq0RiOpV.js +4 -0
  14. package/dist/web/assets/{esm-K1XIK4vc.js → esm-B3je8j5P.js} +1 -1
  15. package/dist/web/assets/{extension-webview-HILvTnnn.js → extension-webview-DxP22X_y.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-0cJMnFZK.js → image-preview-yX0yZtyd.js} +1 -1
  19. package/dist/web/assets/index-BJ76xcQz.css +2 -0
  20. package/dist/web/assets/index-DJQJu6Ef.js +27 -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-zxSQXdFL.js +1 -0
  24. package/dist/web/assets/{markdown-renderer-D0MrsVJB.js → markdown-renderer-DHD3HPwK.js} +3 -3
  25. package/dist/web/assets/packet-RMMSAZCW-DpzHf4xp.js +1 -0
  26. package/dist/web/assets/{pdf-preview-BBVDS-z5.js → pdf-preview-BlRtar7G.js} +1 -1
  27. package/dist/web/assets/pie-UPGHQEXC-BpzFCKJ8.js +1 -0
  28. package/dist/web/assets/{port-forwarding-tab-ByKzBs-R.js → port-forwarding-tab-DOYZIXHo.js} +1 -1
  29. package/dist/web/assets/{postgres-viewer-BnCbdR7g.js → postgres-viewer-DM6b5mZl.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-JzeC-QC7.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-IvosQxK2.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-D4xxia2I.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-CKaht6nI.js → video-preview-ClY8ALGJ.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/project-changelog.md +9 -1
  47. package/package.json +1 -1
  48. package/src/server/routes/chat.ts +2 -1
  49. package/src/services/file-filter.service.ts +17 -4
  50. package/src/services/file-list-index.service.ts +7 -3
  51. package/src/services/jsonl-transcript-parser.ts +10 -1
  52. package/src/services/supervisor.ts +35 -35
  53. package/src/types/project.ts +2 -0
  54. package/src/web/app.tsx +4 -0
  55. package/src/web/components/chat/message-list.tsx +49 -2
  56. package/src/web/components/editor/compare-picker.tsx +245 -0
  57. package/src/web/components/explorer/file-tree.tsx +42 -1
  58. package/src/web/components/layout/command-palette.tsx +31 -1
  59. package/src/web/components/layout/draggable-tab.tsx +8 -0
  60. package/src/web/components/layout/tab-bar.tsx +101 -27
  61. package/src/web/hooks/use-chat.ts +8 -1
  62. package/src/web/hooks/use-global-keybindings.ts +20 -0
  63. package/src/web/lib/open-compare-tab.ts +76 -0
  64. package/src/web/stores/compare-store.ts +57 -0
  65. package/src/web/stores/keybindings-store.ts +1 -0
  66. package/dist/web/assets/architecture-PBZL5I3N-DvZbltvY.js +0 -1
  67. package/dist/web/assets/chat-tab-umei1UkV.js +0 -12
  68. package/dist/web/assets/code-editor-BTosKXkr.js +0 -8
  69. package/dist/web/assets/columns-2-4fQcE4PF.js +0 -1
  70. package/dist/web/assets/diff-viewer-DKLeIBkK.js +0 -4
  71. package/dist/web/assets/extension-store-3yZYn07W.js +0 -1
  72. package/dist/web/assets/gitGraph-HDMCJU4V-BxhdxFgj.js +0 -1
  73. package/dist/web/assets/index-Bce0weeW.css +0 -2
  74. package/dist/web/assets/index-DDBvHVVr.js +0 -27
  75. package/dist/web/assets/info-3K5VOQVL-BwAZ2zd8.js +0 -1
  76. package/dist/web/assets/keybindings-store-B-zET-0o.js +0 -1
  77. package/dist/web/assets/keybindings-store-DaBV6qhz.js +0 -1
  78. package/dist/web/assets/packet-RMMSAZCW-tx2n5Qry.js +0 -1
  79. package/dist/web/assets/pie-UPGHQEXC-D6S2MqVT.js +0 -1
  80. package/dist/web/assets/radar-KQ55EAFF-BviZcL-b.js +0 -1
  81. package/dist/web/assets/settings-tab-BPdzUw3v.js +0 -1
  82. package/dist/web/assets/sqlite-viewer-D6mSIIx2.js +0 -1
  83. package/dist/web/assets/terminal-tab-BLIA53mt.js +0 -1
  84. package/dist/web/assets/treemap-KZPCXAKY-CM54VdaB.js +0 -1
  85. /package/dist/web/assets/{api-client-Dvzcc_EO.js → api-client-r4nyVy7H.js} +0 -0
  86. /package/dist/web/assets/{csv-parser--2WJNgS7.js → csv-parser-DxVplKKB.js} +0 -0
  87. /package/dist/web/assets/{database-D4DIhgi-.js → database-DCT0OjgQ.js} +0 -0
  88. /package/dist/web/assets/{dist-im4ynINo.js → dist-BqoEabX7.js} +0 -0
  89. /package/dist/web/assets/{file-exclamation-point-BwzaQ50n.js → file-exclamation-point-Baz81y5z.js} +0 -0
  90. /package/dist/web/assets/{katex-CKoArbIw.js → katex-bpagxk3Z.js} +0 -0
  91. /package/dist/web/assets/{lib-DQHnkzGy.js → lib-BqkcKGFq.js} +0 -0
  92. /package/dist/web/assets/{react-GqWghJ-L.js → react-BkWDCPD7.js} +0 -0
  93. /package/dist/web/assets/{refresh-cw-LlbZDJpO.js → refresh-cw-CSFrDtiu.js} +0 -0
  94. /package/dist/web/assets/{sql-completion-provider-C3cq9j99.js → sql-completion-provider-EzHOQLfo.js} +0 -0
  95. /package/dist/web/assets/{table-Dq575bPF.js → table-DbSviOmw.js} +0 -0
  96. /package/dist/web/assets/{text-wrap-Cn6BNQfq.js → text-wrap-DzvCTq_i.js} +0 -0
  97. /package/dist/web/assets/{trash-2-CJYoLw7Q.js → trash-2-BgDIBl6f.js} +0 -0
  98. /package/dist/web/assets/{utils-CTg5uAYR.js → utils-ChWX7pZv.js} +0 -0
  99. /package/dist/web/assets/{vendor-xterm-CU2c3f0A.js → vendor-xterm-D7SePDJp.js} +0 -0
  100. /package/dist/web/assets/{x-DlFGzN8d.js → x-BtqbfkR7.js} +0 -0
@@ -0,0 +1,245 @@
1
+ import { useState, useMemo, useEffect, useRef } from "react";
2
+ import { Columns2, FileCode, X } from "lucide-react";
3
+ import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
4
+ import { useTabStore } from "@/stores/tab-store";
5
+ import { useFileStore } from "@/stores/file-store";
6
+ import { useProjectStore } from "@/stores/project-store";
7
+ import { useCompareStore, type CompareSelection } from "@/stores/compare-store";
8
+ import { openCompareTab } from "@/lib/open-compare-tab";
9
+ import { basename, cn } from "@/lib/utils";
10
+ import { scoreFileSearch, compareScores } from "@/lib/score-file-search";
11
+
12
+ interface Candidate {
13
+ id: string;
14
+ path: string;
15
+ label: string;
16
+ source: "tab" | "file";
17
+ dirtyContent?: string;
18
+ }
19
+
20
+ interface ComparePickerProps {
21
+ /** Controlled mode: parent manages open state. Omit both to use singleton/event mode. */
22
+ open?: boolean;
23
+ onOpenChange?: (o: boolean) => void;
24
+ /** If provided, dialog pre-seeds Side A. Ignored in singleton mode (reads from store on open). */
25
+ initialA?: CompareSelection | null;
26
+ }
27
+
28
+ const MAX_RESULTS = 50;
29
+
30
+ /**
31
+ * File-compare picker.
32
+ *
33
+ * Two modes:
34
+ * - Controlled: pass `open`+`onOpenChange` (for tests / programmatic callers).
35
+ * - Singleton: mount once at app root with no props — listens for
36
+ * `window` event `open-compare-picker` and seeds Side A from `useCompareStore`.
37
+ */
38
+ export function ComparePicker({ open: openProp, onOpenChange, initialA }: ComparePickerProps = {}) {
39
+ const controlled = openProp !== undefined;
40
+ const [internalOpen, setInternalOpen] = useState(false);
41
+ const open = controlled ? openProp : internalOpen;
42
+ const setOpen = (o: boolean) => {
43
+ if (controlled) onOpenChange?.(o);
44
+ else setInternalOpen(o);
45
+ };
46
+
47
+ const [localA, setLocalA] = useState<CompareSelection | null>(initialA ?? null);
48
+
49
+ // Singleton mode: listen for global event, seed A from store
50
+ useEffect(() => {
51
+ if (controlled) return;
52
+ function onEvent() {
53
+ setLocalA(useCompareStore.getState().selection);
54
+ setInternalOpen(true);
55
+ }
56
+ window.addEventListener("open-compare-picker", onEvent);
57
+ return () => window.removeEventListener("open-compare-picker", onEvent);
58
+ }, [controlled]);
59
+ const [query, setQuery] = useState("");
60
+ const [activeIndex, setActiveIndex] = useState(0);
61
+ const [error, setError] = useState<string | null>(null);
62
+ const inputRef = useRef<HTMLInputElement>(null);
63
+
64
+ const tabs = useTabStore((s) => s.tabs);
65
+ const fileIndex = useFileStore((s) => s.fileIndex);
66
+ const activeProject = useProjectStore((s) => s.activeProject);
67
+
68
+ useEffect(() => {
69
+ if (!open) return;
70
+ // In controlled mode, sync A from prop. In singleton mode, event handler
71
+ // already populated localA — don't clobber it here.
72
+ if (controlled) setLocalA(initialA ?? null);
73
+ setQuery("");
74
+ setActiveIndex(0);
75
+ setError(null);
76
+ // Focus input after dialog mounts
77
+ setTimeout(() => inputRef.current?.focus(), 50);
78
+ }, [open, initialA, controlled]);
79
+
80
+ const candidates = useMemo<Candidate[]>(() => {
81
+ const tabCands: Candidate[] = tabs
82
+ .filter((t) => t.type === "editor" && t.metadata?.filePath)
83
+ .map((t) => ({
84
+ id: `tab:${t.id}`,
85
+ path: t.metadata!.filePath as string,
86
+ label: basename(t.metadata!.filePath as string),
87
+ source: "tab",
88
+ dirtyContent: t.metadata!.unsavedContent as string | undefined,
89
+ }));
90
+ const seenPaths = new Set(tabCands.map((c) => c.path));
91
+ const fileCands: Candidate[] = fileIndex
92
+ .filter((f) => f.type === "file" && !seenPaths.has(f.path))
93
+ .map((f) => ({
94
+ id: `file:${f.path}`,
95
+ path: f.path,
96
+ label: f.name,
97
+ source: "file",
98
+ }));
99
+ return [...tabCands, ...fileCands];
100
+ }, [tabs, fileIndex]);
101
+
102
+ const filtered = useMemo<Candidate[]>(() => {
103
+ if (!query.trim()) return candidates.slice(0, MAX_RESULTS);
104
+ const scored = candidates
105
+ .map((c) => {
106
+ const score = scoreFileSearch(query, c.label, c.path);
107
+ return score ? { c, score } : null;
108
+ })
109
+ .filter((x): x is { c: Candidate; score: ReturnType<typeof scoreFileSearch> & {} } => x !== null)
110
+ .sort((a, b) => compareScores(a.score, b.score))
111
+ .slice(0, MAX_RESULTS)
112
+ .map((x) => x.c);
113
+ return scored;
114
+ }, [candidates, query]);
115
+
116
+ useEffect(() => {
117
+ if (activeIndex >= filtered.length) setActiveIndex(Math.max(0, filtered.length - 1));
118
+ }, [filtered, activeIndex]);
119
+
120
+ // Guards against rapid double-invoke (Enter spam, double-click) while the
121
+ // openCompareTab promise is in flight — ref so a second sync call sees it.
122
+ const pickingRef = useRef(false);
123
+
124
+ async function handlePick(c: Candidate) {
125
+ if (!activeProject) return;
126
+ if (!localA) {
127
+ setLocalA({
128
+ filePath: c.path,
129
+ projectName: activeProject.name,
130
+ dirtyContent: c.dirtyContent,
131
+ label: c.label,
132
+ });
133
+ setQuery("");
134
+ setActiveIndex(0);
135
+ inputRef.current?.focus();
136
+ return;
137
+ }
138
+ if (pickingRef.current) return;
139
+ pickingRef.current = true;
140
+ try {
141
+ await openCompareTab(
142
+ { path: localA.filePath, dirtyContent: localA.dirtyContent },
143
+ { path: c.path, dirtyContent: c.dirtyContent },
144
+ activeProject.name,
145
+ );
146
+ useCompareStore.getState().clearSelection();
147
+ setOpen(false);
148
+ } catch (err) {
149
+ setError(err instanceof Error ? err.message : "Compare failed");
150
+ } finally {
151
+ pickingRef.current = false;
152
+ }
153
+ }
154
+
155
+ function handleKeyDown(e: React.KeyboardEvent) {
156
+ if (e.key === "ArrowDown") {
157
+ e.preventDefault();
158
+ setActiveIndex((i) => Math.min(filtered.length - 1, i + 1));
159
+ } else if (e.key === "ArrowUp") {
160
+ e.preventDefault();
161
+ setActiveIndex((i) => Math.max(0, i - 1));
162
+ } else if (e.key === "Enter") {
163
+ e.preventDefault();
164
+ const pick = filtered[activeIndex];
165
+ if (pick) handlePick(pick);
166
+ }
167
+ }
168
+
169
+ return (
170
+ <Dialog open={open} onOpenChange={setOpen}>
171
+ <DialogContent className="max-w-lg p-0 gap-0 overflow-hidden">
172
+ <DialogHeader className="px-4 pt-4 pb-2">
173
+ <DialogTitle className="flex items-center gap-2 text-sm">
174
+ <Columns2 className="size-4" />
175
+ Compare Files
176
+ </DialogTitle>
177
+ </DialogHeader>
178
+
179
+ {/* Side A chip */}
180
+ <div className="px-4 pb-2">
181
+ {localA ? (
182
+ <div className="flex items-center gap-2 text-xs bg-muted rounded px-2 py-1 w-fit max-w-full">
183
+ <FileCode className="size-3.5 shrink-0" />
184
+ <span className="truncate" title={localA.filePath}>{localA.label}</span>
185
+ <button
186
+ type="button"
187
+ onClick={() => setLocalA(null)}
188
+ className="hover:bg-surface-elevated rounded p-0.5"
189
+ aria-label="Clear first file"
190
+ >
191
+ <X className="size-3" />
192
+ </button>
193
+ </div>
194
+ ) : (
195
+ <p className="text-xs text-muted-foreground">Pick first file, then second.</p>
196
+ )}
197
+ </div>
198
+
199
+ {/* Search input */}
200
+ <input
201
+ ref={inputRef}
202
+ type="text"
203
+ value={query}
204
+ onChange={(e) => { setQuery(e.target.value); setActiveIndex(0); }}
205
+ onKeyDown={handleKeyDown}
206
+ placeholder={localA ? "Search for file B..." : "Search for file A..."}
207
+ className="w-full px-4 py-2 bg-transparent border-y border-border text-sm outline-none"
208
+ />
209
+
210
+ {error && (
211
+ <div className="px-4 py-2 text-xs text-destructive border-b border-border">{error}</div>
212
+ )}
213
+
214
+ {/* Results list */}
215
+ <div className="max-h-[50vh] md:max-h-80 overflow-y-auto">
216
+ {filtered.length === 0 ? (
217
+ <div className="px-4 py-6 text-center text-xs text-muted-foreground">
218
+ {candidates.length === 0 ? "No files available" : "No matches"}
219
+ </div>
220
+ ) : (
221
+ filtered.map((c, i) => (
222
+ <button
223
+ key={c.id}
224
+ type="button"
225
+ onClick={() => handlePick(c)}
226
+ onMouseEnter={() => setActiveIndex(i)}
227
+ className={cn(
228
+ "w-full flex items-center gap-2 px-4 py-1.5 text-left text-sm",
229
+ "hover:bg-surface-elevated transition-colors",
230
+ i === activeIndex && "bg-surface-elevated",
231
+ )}
232
+ >
233
+ <FileCode className="size-3.5 shrink-0 text-text-secondary" />
234
+ <span className="truncate">{c.label}</span>
235
+ <span className="text-xs text-muted-foreground truncate ml-auto" title={c.path}>
236
+ {c.source === "tab" ? "open" : c.path}
237
+ </span>
238
+ </button>
239
+ ))
240
+ )}
241
+ </div>
242
+ </DialogContent>
243
+ </Dialog>
244
+ );
245
+ }
@@ -25,6 +25,9 @@ import { useShallow } from "zustand/react/shallow";
25
25
  import { useFileStore, type FileNode } from "@/stores/file-store";
26
26
  import { useProjectStore } from "@/stores/project-store";
27
27
  import { useTabStore } from "@/stores/tab-store";
28
+ import { useCompareStore } from "@/stores/compare-store";
29
+ import { openCompareTab } from "@/lib/open-compare-tab";
30
+ import { toast } from "sonner";
28
31
  import { cn, basename } from "@/lib/utils";
29
32
  import { ScrollArea } from "@/components/ui/scroll-area";
30
33
  import {
@@ -119,6 +122,7 @@ const TreeNode = memo(function TreeNode({ node, depth, projectName, onAction, on
119
122
  })),
120
123
  );
121
124
  const openTab = useTabStore((s) => s.openTab);
125
+ const compareSelection = useCompareStore((s) => s.selection);
122
126
  const isExpanded = expandedPaths.has(node.path);
123
127
  const isDir = node.type === "directory";
124
128
  const isSelected = selectedFiles.includes(node.path);
@@ -270,6 +274,19 @@ const TreeNode = memo(function TreeNode({ node, depth, projectName, onAction, on
270
274
  <Download className="size-3.5 mr-2" />
271
275
  Download{isDir ? " as Zip" : ""}
272
276
  </ContextMenuItem>
277
+ {!isDir && (
278
+ <>
279
+ <ContextMenuSeparator />
280
+ <ContextMenuItem onClick={() => onAction("select-for-compare", node)}>
281
+ Select for Compare
282
+ </ContextMenuItem>
283
+ {compareSelection && compareSelection.projectName === projectName && compareSelection.filePath !== node.path && (
284
+ <ContextMenuItem onClick={() => onAction("compare-with-selected", node)}>
285
+ Compare with Selected ({compareSelection.label})
286
+ </ContextMenuItem>
287
+ )}
288
+ </>
289
+ )}
273
290
  {!isDir && selectedFiles.length === 2 && (
274
291
  <>
275
292
  <ContextMenuSeparator />
@@ -445,11 +462,35 @@ export function FileTree({ onFileOpen }: FileTreeProps = {}) {
445
462
  if (e.dataTransfer.files.length > 0) uploadFiles("", e.dataTransfer.files);
446
463
  }
447
464
 
448
- function handleAction(action: string, node: FileNode) {
465
+ async function handleAction(action: string, node: FileNode) {
449
466
  if (action === "copy-path") {
450
467
  navigator.clipboard.writeText(node.path).catch(() => {});
451
468
  return;
452
469
  }
470
+ if (action === "select-for-compare") {
471
+ useCompareStore.getState().setSelection({
472
+ filePath: node.path,
473
+ projectName: activeProject!.name,
474
+ label: node.name,
475
+ });
476
+ return;
477
+ }
478
+ if (action === "compare-with-selected") {
479
+ const sel = useCompareStore.getState().selection;
480
+ if (!sel) return;
481
+ try {
482
+ await openCompareTab(
483
+ { path: sel.filePath, dirtyContent: sel.dirtyContent },
484
+ { path: node.path },
485
+ activeProject!.name,
486
+ );
487
+ useCompareStore.getState().clearSelection();
488
+ } catch (err) {
489
+ const msg = err instanceof Error ? err.message : "Compare failed";
490
+ toast.error(msg);
491
+ }
492
+ return;
493
+ }
453
494
  if (action === "download") {
454
495
  if (node.type === "directory") {
455
496
  downloadFolder(activeProject!.name, node.path);
@@ -16,6 +16,7 @@ import {
16
16
  Mic,
17
17
  RefreshCw,
18
18
  Plus,
19
+ Columns2,
19
20
  } from "lucide-react";
20
21
  import { useTabStore, type TabType } from "@/stores/tab-store";
21
22
  import { useProjectStore } from "@/stores/project-store";
@@ -23,6 +24,7 @@ import { useSettingsStore } from "@/stores/settings-store";
23
24
  import { useKeybindingsStore } from "@/stores/keybindings-store";
24
25
  import { useFileStore, type FileNode } from "@/stores/file-store";
25
26
  import { useExtensionStore } from "@/stores/extension-store";
27
+ import { useCompareStore } from "@/stores/compare-store";
26
28
  import { api } from "@/lib/api-client";
27
29
  import { basename } from "@/lib/utils";
28
30
  import { scoreFileSearchFast, compareScores, getFilename, type FileSearchScore } from "@/lib/score-file-search";
@@ -40,6 +42,8 @@ interface CommandItem {
40
42
  group: "action" | "file" | "fs" | "db";
41
43
  connectionColor?: string | null;
42
44
  shortcut?: string;
45
+ /** True if gitignored — rendered with muted style for visual cue */
46
+ isIgnored?: boolean;
43
47
  }
44
48
 
45
49
  const isMac = typeof navigator !== "undefined" && /Mac|iPhone|iPad/.test(navigator.userAgent);
@@ -189,6 +193,29 @@ export function CommandPalette({ open, onClose, initialQuery = "" }: { open: boo
189
193
  { id: "postgres", label: "PostgreSQL", icon: Database, action: openNewTab("postgres", "PostgreSQL"), keywords: "database pg sql query", group: "action" },
190
194
  { id: "voice-input", label: "Voice Input", icon: Mic, action: () => { window.dispatchEvent(new CustomEvent("toggle-voice-input")); onClose(); }, keywords: "speech microphone dictate voice", group: "action", shortcut: formatShortcut(getBinding("voice-input")) },
191
195
  { id: "git-status", label: "Git Status", icon: GitCommitHorizontal, action: () => { setSidebarActiveTab("git"); onClose(); }, keywords: "changes diff staged", group: "action", shortcut: formatShortcut(getBinding("open-git-status")) },
196
+ {
197
+ id: "compare-files",
198
+ label: "Compare Files...",
199
+ icon: Columns2,
200
+ group: "action",
201
+ keywords: "diff compare two files select",
202
+ shortcut: formatShortcut(getBinding("compare-files")),
203
+ action: () => {
204
+ const { activeTabId: tid, tabs: ts } = useTabStore.getState();
205
+ const active = ts.find((t) => t.id === tid);
206
+ const meta = active?.metadata as { filePath?: string; projectName?: string; unsavedContent?: string } | undefined;
207
+ if (active?.type === "editor" && meta?.filePath && meta?.projectName) {
208
+ useCompareStore.getState().setSelection({
209
+ filePath: meta.filePath,
210
+ projectName: meta.projectName,
211
+ dirtyContent: meta.unsavedContent,
212
+ label: basename(meta.filePath),
213
+ });
214
+ }
215
+ window.dispatchEvent(new CustomEvent("open-compare-picker"));
216
+ onClose();
217
+ },
218
+ },
192
219
  {
193
220
  id: "settings", label: "Settings", icon: Settings,
194
221
  action: () => {
@@ -244,6 +271,8 @@ export function CommandPalette({ open, onClose, initialQuery = "" }: { open: boo
244
271
  icon: FileCode,
245
272
  group: "file" as const,
246
273
  keywords: f.path,
274
+ // Propagate gitignore flag for muted rendering (only present on /files/index entries)
275
+ isIgnored: ("isIgnored" in f ? f.isIgnored : undefined) as boolean | undefined,
247
276
  action: () => {
248
277
  openTab({
249
278
  type: "editor",
@@ -500,7 +529,8 @@ export function CommandPalette({ open, onClose, initialQuery = "" }: { open: boo
500
529
  i === selectedIdx
501
530
  ? "bg-accent/15 text-text-primary"
502
531
  : "text-text-secondary hover:bg-surface-elevated"
503
- }`}
532
+ } ${cmd.isIgnored ? "opacity-60" : ""}`}
533
+ title={cmd.isIgnored ? "Gitignored file" : undefined}
504
534
  >
505
535
  <Icon className="size-4 shrink-0" />
506
536
  <span className="truncate">{cmd.label}</span>
@@ -162,6 +162,14 @@ export function DraggableTab({
162
162
  {showDropBefore && (
163
163
  <div className="absolute left-0 top-1 bottom-1 w-0.5 bg-primary rounded-full z-10" />
164
164
  )}
165
+ {tagColor && (
166
+ // Tag identity marker — VS Code-style vertical bar on left edge (centered, ~60% height, rounded right)
167
+ <span
168
+ aria-hidden
169
+ className="absolute left-0 top-2 bottom-2 w-[3px] rounded-r-full pointer-events-none"
170
+ style={{ backgroundColor: tagColor }}
171
+ />
172
+ )}
165
173
  {onContextAction ? (
166
174
  <ContextMenu>
167
175
  <ContextMenuTrigger asChild>
@@ -16,6 +16,9 @@ import { useTabStore, type TabType } from "@/stores/tab-store";
16
16
  import { usePanelStore } from "@/stores/panel-store";
17
17
  import { useProjectStore } from "@/stores/project-store";
18
18
  import { useFileStore, type FileNode } from "@/stores/file-store";
19
+ import { useCompareStore } from "@/stores/compare-store";
20
+ import { openCompareTab } from "@/lib/open-compare-tab";
21
+ import { toast } from "sonner";
19
22
  import { useTabDrag } from "@/hooks/use-tab-drag";
20
23
  import { useTouchTabDrag, wasTouchDragRecent } from "@/hooks/use-touch-tab-drag";
21
24
  import { openCommandPalette } from "@/hooks/use-global-keybindings";
@@ -25,7 +28,8 @@ import {
25
28
  ContextMenuSub, ContextMenuSubTrigger, ContextMenuSubContent,
26
29
  ContextMenuItem, ContextMenuSeparator,
27
30
  } from "@/components/ui/context-menu";
28
- import { Tag, Check } from "lucide-react";
31
+ import { Tag, Check, Columns2 } from "lucide-react";
32
+ import { basename } from "@/lib/utils";
29
33
  import { useNotificationStore, notificationColor } from "@/stores/notification-store";
30
34
  import { useStreamingStore } from "@/stores/streaming-store";
31
35
  import { useTabOverflow, getHiddenUnreadDirection } from "@/hooks/use-tab-overflow";
@@ -128,9 +132,74 @@ export const TabBar = memo(function TabBar({ panelId }: TabBarProps) {
128
132
  }
129
133
  }, []);
130
134
 
135
+ // Compare selection — re-renders menu when selection changes
136
+ const compareSelection = useCompareStore((s) => s.selection);
137
+
131
138
  // File action dialog state for tab context menu (rename/delete)
132
139
  const [fileActionState, setFileActionState] = useState<{ action: string; node: FileNode; tabId: string } | null>(null);
133
140
 
141
+ /**
142
+ * Build "Select for Compare" + "Compare with Selected" menu items for a tab.
143
+ * Returns null for non-file tabs so menu stays clean.
144
+ */
145
+ function compareMenuItems(tab: Tab): React.ReactNode {
146
+ if (tab.type !== "editor") return null;
147
+ const filePath = tab.metadata?.filePath as string | undefined;
148
+ const projectName = tab.metadata?.projectName as string | undefined;
149
+ if (!filePath || !projectName) return null;
150
+
151
+ // Only show "Compare with Selected" when same project (cross-project
152
+ // selection is auto-cleared on project switch, but guard covers the
153
+ // brief window before the subscription fires).
154
+ const hasDifferentSelection =
155
+ compareSelection != null &&
156
+ compareSelection.projectName === projectName &&
157
+ compareSelection.filePath !== filePath;
158
+
159
+ return (
160
+ <>
161
+ <ContextMenuItem
162
+ onClick={() => {
163
+ const unsaved = tab.metadata?.unsavedContent as string | undefined;
164
+ useCompareStore.getState().setSelection({
165
+ filePath,
166
+ projectName,
167
+ dirtyContent: unsaved,
168
+ label: basename(filePath),
169
+ });
170
+ }}
171
+ >
172
+ <Columns2 className="size-3.5 mr-2" />
173
+ Select for Compare
174
+ </ContextMenuItem>
175
+ {hasDifferentSelection && (
176
+ <ContextMenuItem
177
+ onClick={async () => {
178
+ const sel = useCompareStore.getState().selection;
179
+ if (!sel) return;
180
+ const unsaved = tab.metadata?.unsavedContent as string | undefined;
181
+ try {
182
+ await openCompareTab(
183
+ { path: sel.filePath, dirtyContent: sel.dirtyContent },
184
+ { path: filePath, dirtyContent: unsaved },
185
+ projectName,
186
+ );
187
+ useCompareStore.getState().clearSelection();
188
+ } catch (err) {
189
+ const msg = err instanceof Error ? err.message : "Compare failed";
190
+ toast.error(msg);
191
+ }
192
+ }}
193
+ >
194
+ <Columns2 className="size-3.5 mr-2" />
195
+ Compare with Selected ({compareSelection!.label})
196
+ </ContextMenuItem>
197
+ )}
198
+ <ContextMenuSeparator />
199
+ </>
200
+ );
201
+ }
202
+
134
203
  /** Handle context menu actions on a tab */
135
204
  const handleTabContextAction = useCallback((tab: Tab, action: string) => {
136
205
  const panelState = usePanelStore.getState();
@@ -257,34 +326,39 @@ export const TabBar = memo(function TabBar({ panelId }: TabBarProps) {
257
326
  onRename={tab.type === "chat" ? (title) => handleRenameTab(tab, title) : undefined}
258
327
  onContextAction={(action) => handleTabContextAction(tab, action)}
259
328
  tagColor={sessionId ? sessionTagMap[sessionId]?.color : undefined}
260
- extraMenuContent={sessionId && projectTags.length > 0 ? (
329
+ extraMenuContent={
261
330
  <>
262
- <ContextMenuSub>
263
- <ContextMenuSubTrigger>
264
- <Tag className="size-3.5 mr-2" />
265
- Set Tag
266
- </ContextMenuSubTrigger>
267
- <ContextMenuSubContent>
268
- {projectTags.map((pt) => (
269
- <ContextMenuItem key={pt.id} onClick={() => assignTagToSession(sessionId, pt.id)}>
270
- <span className="size-2.5 rounded-full mr-2 shrink-0" style={{ backgroundColor: pt.color }} />
271
- {pt.name}
272
- {sessionTagMap[sessionId]?.id === pt.id && <Check className="size-3 ml-auto" />}
273
- </ContextMenuItem>
274
- ))}
275
- {sessionTagMap[sessionId] && (
276
- <>
277
- <ContextMenuSeparator />
278
- <ContextMenuItem onClick={() => assignTagToSession(sessionId, null)}>
279
- Remove tag
280
- </ContextMenuItem>
281
- </>
282
- )}
283
- </ContextMenuSubContent>
284
- </ContextMenuSub>
285
- <ContextMenuSeparator />
331
+ {compareMenuItems(tab)}
332
+ {sessionId && projectTags.length > 0 && (
333
+ <>
334
+ <ContextMenuSub>
335
+ <ContextMenuSubTrigger>
336
+ <Tag className="size-3.5 mr-2" />
337
+ Set Tag
338
+ </ContextMenuSubTrigger>
339
+ <ContextMenuSubContent>
340
+ {projectTags.map((pt) => (
341
+ <ContextMenuItem key={pt.id} onClick={() => assignTagToSession(sessionId, pt.id)}>
342
+ <span className="size-2.5 rounded-full mr-2 shrink-0" style={{ backgroundColor: pt.color }} />
343
+ {pt.name}
344
+ {sessionTagMap[sessionId]?.id === pt.id && <Check className="size-3 ml-auto" />}
345
+ </ContextMenuItem>
346
+ ))}
347
+ {sessionTagMap[sessionId] && (
348
+ <>
349
+ <ContextMenuSeparator />
350
+ <ContextMenuItem onClick={() => assignTagToSession(sessionId, null)}>
351
+ Remove tag
352
+ </ContextMenuItem>
353
+ </>
354
+ )}
355
+ </ContextMenuSubContent>
356
+ </ContextMenuSub>
357
+ <ContextMenuSeparator />
358
+ </>
359
+ )}
286
360
  </>
287
- ) : undefined}
361
+ }
288
362
  />
289
363
  );
290
364
  })}
@@ -816,7 +816,14 @@ export function useChat(sessionId: string | null, providerId = "claude", project
816
816
  /** Fetch pre-compact transcript. Idempotent: re-expanding same id replaces entry. */
817
817
  const expandCompact = useCallback(async (compactMessageId: string, jsonlPath: string): Promise<number> => {
818
818
  if (!projectName) throw new Error("No project context available");
819
- const url = `${projectUrl(projectName)}/chat/pre-compact-messages?jsonlPath=${encodeURIComponent(jsonlPath)}`;
819
+ // Claude's compact summary references the CURRENT session file (pre+summary+post).
820
+ // Strip the `pc-{hash}-` prefix added by prefixPreCompactIds for nested expansions
821
+ // so BE receives the raw session uuid and truncates at the correct boundary.
822
+ const rawUuid = compactMessageId.replace(/^pc-[^-]+-/, "");
823
+ const url =
824
+ `${projectUrl(projectName)}/chat/pre-compact-messages` +
825
+ `?jsonlPath=${encodeURIComponent(jsonlPath)}` +
826
+ `&before=${encodeURIComponent(rawUuid)}`;
820
827
  const loaded = await api.get<ChatMessage[]>(url);
821
828
  const prefixed = prefixPreCompactIds(loaded, jsonlPath);
822
829
  setExpansions((prev) => {
@@ -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();