@hienlh/ppm 0.13.3 → 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.
- package/CHANGELOG.md +22 -0
- package/assets/skills/ppm/SKILL.md +1 -1
- package/assets/skills/ppm/references/http-api.md +1 -1
- package/dist/web/assets/{ai-settings-section-QE6nBNgN.js → ai-settings-section-DeW4WN43.js} +1 -1
- package/dist/web/assets/{api-settings-DAk7D-NP.js → api-settings-t7Leca7J.js} +1 -1
- package/dist/web/assets/architecture-PBZL5I3N-Dy3PgD6O.js +1 -0
- package/dist/web/assets/{audio-preview-R7cq1uhJ.js → audio-preview-BSAe2WQB.js} +1 -1
- package/dist/web/assets/chat-tab-UFEFOnpl.js +12 -0
- package/dist/web/assets/code-editor-BJ1tSNWA.js +8 -0
- package/dist/web/assets/{conflict-editor-dzofjxab.js → conflict-editor-CrgrMZ2F.js} +1 -1
- package/dist/web/assets/{csv-preview-HMSavgBb.js → csv-preview-C9qGhDlb.js} +1 -1
- package/dist/web/assets/{database-viewer-5Uf8Rrls.js → database-viewer-e_NAkIL_.js} +2 -2
- package/dist/web/assets/diff-viewer-C2eOczTs.js +4 -0
- package/dist/web/assets/{esm-K1XIK4vc.js → esm-B3je8j5P.js} +1 -1
- package/dist/web/assets/{extension-webview-HILvTnnn.js → extension-webview-B95nOfj-.js} +2 -2
- package/dist/web/assets/{file-store-BrbCNyLm.js → file-store-BgZggznw.js} +1 -1
- package/dist/web/assets/gitGraph-HDMCJU4V-Bu1SIFFq.js +1 -0
- package/dist/web/assets/{image-preview-0cJMnFZK.js → image-preview-DAuPOzYl.js} +1 -1
- package/dist/web/assets/index-DJOjXTcq.js +27 -0
- package/dist/web/assets/index-DSOP0R0s.css +2 -0
- package/dist/web/assets/info-3K5VOQVL-DzfAxmVd.js +1 -0
- package/dist/web/assets/{input-Dk49gO8E.js → input-bGJExpJZ.js} +1 -1
- package/dist/web/assets/keybindings-store-V12kZZHO.js +1 -0
- package/dist/web/assets/{markdown-renderer-D0MrsVJB.js → markdown-renderer-DwINRWo4.js} +3 -3
- package/dist/web/assets/packet-RMMSAZCW-DpzHf4xp.js +1 -0
- package/dist/web/assets/{pdf-preview-BBVDS-z5.js → pdf-preview-CqoQE09t.js} +1 -1
- package/dist/web/assets/pie-UPGHQEXC-BpzFCKJ8.js +1 -0
- package/dist/web/assets/{port-forwarding-tab-ByKzBs-R.js → port-forwarding-tab-De7qxkjp.js} +1 -1
- package/dist/web/assets/{postgres-viewer-BnCbdR7g.js → postgres-viewer-Dd6rLb8b.js} +3 -3
- package/dist/web/assets/radar-KQ55EAFF-DAxWKxM4.js +1 -0
- package/dist/web/assets/{scroll-area-BEllam7_.js → scroll-area-D0EQpAH2.js} +1 -1
- package/dist/web/assets/{settings-store-BLLR7ed8.js → settings-store-CdcSAgEZ.js} +2 -2
- package/dist/web/assets/settings-tab-BdTEumwU.js +1 -0
- package/dist/web/assets/{sql-query-editor-CVAnRFbi.js → sql-query-editor-vpD0I0KG.js} +1 -1
- package/dist/web/assets/sqlite-viewer-Ccz2crvN.js +1 -0
- package/dist/web/assets/{tab-store-B3M9hjho.js → tab-store-Jvy1eZGM.js} +1 -1
- package/dist/web/assets/terminal-tab-D7u7wsyb.js +1 -0
- package/dist/web/assets/treemap-KZPCXAKY-D6dgXbAe.js +1 -0
- package/dist/web/assets/{use-blob-url-e9uTXjv5.js → use-blob-url-BgxxT-n_.js} +1 -1
- package/dist/web/assets/{use-monaco-theme-BkZDwoVd.js → use-monaco-theme-dtPsv6sh.js} +1 -1
- package/dist/web/assets/{vendor-mermaid-Dx86tuVP.js → vendor-mermaid-DCxaaPi4.js} +2 -2
- package/dist/web/assets/{video-preview-CKaht6nI.js → video-preview-BSDzqlzk.js} +1 -1
- package/dist/web/index.html +17 -19
- package/dist/web/sw.js +1 -1
- package/docs/codebase-summary.md +2 -0
- package/docs/journals/2026-04-22-compare-files-feature-ship.md +53 -0
- package/docs/project-changelog.md +9 -1
- package/package.json +1 -1
- package/src/services/file-filter.service.ts +17 -4
- package/src/services/file-list-index.service.ts +7 -3
- package/src/types/project.ts +2 -0
- package/src/web/app.tsx +4 -0
- package/src/web/components/editor/compare-picker.tsx +245 -0
- package/src/web/components/explorer/file-tree.tsx +42 -1
- package/src/web/components/layout/command-palette.tsx +31 -1
- package/src/web/components/layout/draggable-tab.tsx +8 -0
- package/src/web/components/layout/tab-bar.tsx +101 -27
- package/src/web/hooks/use-global-keybindings.ts +20 -0
- package/src/web/lib/open-compare-tab.ts +76 -0
- package/src/web/stores/compare-store.ts +57 -0
- package/src/web/stores/keybindings-store.ts +1 -0
- package/dist/web/assets/architecture-PBZL5I3N-DvZbltvY.js +0 -1
- package/dist/web/assets/chat-tab-umei1UkV.js +0 -12
- package/dist/web/assets/code-editor-BTosKXkr.js +0 -8
- package/dist/web/assets/columns-2-4fQcE4PF.js +0 -1
- package/dist/web/assets/diff-viewer-DKLeIBkK.js +0 -4
- package/dist/web/assets/extension-store-3yZYn07W.js +0 -1
- package/dist/web/assets/gitGraph-HDMCJU4V-BxhdxFgj.js +0 -1
- package/dist/web/assets/index-Bce0weeW.css +0 -2
- package/dist/web/assets/index-DDBvHVVr.js +0 -27
- package/dist/web/assets/info-3K5VOQVL-BwAZ2zd8.js +0 -1
- package/dist/web/assets/keybindings-store-B-zET-0o.js +0 -1
- package/dist/web/assets/keybindings-store-DaBV6qhz.js +0 -1
- package/dist/web/assets/packet-RMMSAZCW-tx2n5Qry.js +0 -1
- package/dist/web/assets/pie-UPGHQEXC-D6S2MqVT.js +0 -1
- package/dist/web/assets/radar-KQ55EAFF-BviZcL-b.js +0 -1
- package/dist/web/assets/settings-tab-BPdzUw3v.js +0 -1
- package/dist/web/assets/sqlite-viewer-D6mSIIx2.js +0 -1
- package/dist/web/assets/terminal-tab-BLIA53mt.js +0 -1
- package/dist/web/assets/treemap-KZPCXAKY-CM54VdaB.js +0 -1
- /package/dist/web/assets/{api-client-Dvzcc_EO.js → api-client-r4nyVy7H.js} +0 -0
- /package/dist/web/assets/{csv-parser--2WJNgS7.js → csv-parser-DxVplKKB.js} +0 -0
- /package/dist/web/assets/{database-D4DIhgi-.js → database-DCT0OjgQ.js} +0 -0
- /package/dist/web/assets/{dist-im4ynINo.js → dist-BqoEabX7.js} +0 -0
- /package/dist/web/assets/{file-exclamation-point-BwzaQ50n.js → file-exclamation-point-Baz81y5z.js} +0 -0
- /package/dist/web/assets/{katex-CKoArbIw.js → katex-bpagxk3Z.js} +0 -0
- /package/dist/web/assets/{lib-DQHnkzGy.js → lib-BqkcKGFq.js} +0 -0
- /package/dist/web/assets/{react-GqWghJ-L.js → react-BkWDCPD7.js} +0 -0
- /package/dist/web/assets/{refresh-cw-LlbZDJpO.js → refresh-cw-CSFrDtiu.js} +0 -0
- /package/dist/web/assets/{sql-completion-provider-C3cq9j99.js → sql-completion-provider-EzHOQLfo.js} +0 -0
- /package/dist/web/assets/{table-Dq575bPF.js → table-DbSviOmw.js} +0 -0
- /package/dist/web/assets/{text-wrap-Cn6BNQfq.js → text-wrap-DzvCTq_i.js} +0 -0
- /package/dist/web/assets/{trash-2-CJYoLw7Q.js → trash-2-BgDIBl6f.js} +0 -0
- /package/dist/web/assets/{utils-CTg5uAYR.js → utils-ChWX7pZv.js} +0 -0
- /package/dist/web/assets/{vendor-xterm-CU2c3f0A.js → vendor-xterm-D7SePDJp.js} +0 -0
- /package/dist/web/assets/{x-DlFGzN8d.js → x-BtqbfkR7.js} +0 -0
|
@@ -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-[2px] 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={
|
|
329
|
+
extraMenuContent={
|
|
261
330
|
<>
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
{pt
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
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
|
-
|
|
361
|
+
}
|
|
288
362
|
/>
|
|
289
363
|
);
|
|
290
364
|
})}
|
|
@@ -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
|
+
}
|
|
@@ -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};
|