@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.
- package/CHANGELOG.md +31 -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--hRMnXRZ.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-Dlo25nmt.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-DcBl6OkV.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-D7bGVSEd.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-CfkqnhXJ.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-DyAm7zuA.js → markdown-renderer-DwINRWo4.js} +3 -3
- package/dist/web/assets/packet-RMMSAZCW-DpzHf4xp.js +1 -0
- package/dist/web/assets/{pdf-preview-CZPcuy5c.js → pdf-preview-CqoQE09t.js} +1 -1
- package/dist/web/assets/pie-UPGHQEXC-BpzFCKJ8.js +1 -0
- package/dist/web/assets/{port-forwarding-tab-3RNozlZ5.js → port-forwarding-tab-De7qxkjp.js} +1 -1
- package/dist/web/assets/{postgres-viewer-CXJv4TXc.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-Dfz71RGb.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/providers/claude-agent-sdk.ts +5 -2
- package/src/server/routes/chat.ts +10 -1
- package/src/server/ws/chat.ts +29 -2
- package/src/services/file-filter.service.ts +17 -4
- package/src/services/file-list-index.service.ts +7 -3
- package/src/types/chat.ts +1 -1
- package/src/types/project.ts +2 -0
- package/src/web/app.tsx +4 -0
- package/src/web/components/chat/message-list.tsx +6 -5
- 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 +66 -13
- package/src/web/components/layout/draggable-tab.tsx +13 -5
- package/src/web/components/layout/tab-bar.tsx +101 -27
- package/src/web/hooks/use-chat.ts +6 -0
- package/src/web/hooks/use-global-keybindings.ts +20 -0
- package/src/web/lib/open-compare-tab.ts +76 -0
- package/src/web/lib/score-file-search.ts +41 -21
- 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-4kL3DNxf.js +0 -12
- package/dist/web/assets/code-editor-Caq5_BaF.js +0 -8
- package/dist/web/assets/columns-2-4fQcE4PF.js +0 -1
- package/dist/web/assets/diff-viewer-CCzPq1o-.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-BGFG66Gh.js +0 -27
- package/dist/web/assets/index-Bce0weeW.css +0 -2
- 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-Cnav4g2u.js +0 -1
- package/dist/web/assets/sqlite-viewer-C8WUEFhA.js +0 -1
- package/dist/web/assets/terminal-tab-CaEsMxp8.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
|
@@ -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);
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { useState, useEffect, useRef, useMemo, useCallback } from "react";
|
|
1
|
+
import { useState, useEffect, useRef, useMemo, useCallback, useDeferredValue } from "react";
|
|
2
2
|
import {
|
|
3
3
|
Terminal,
|
|
4
4
|
MessageSquare,
|
|
@@ -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,9 +24,13 @@ 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
|
-
import {
|
|
30
|
+
import { scoreFileSearchFast, compareScores, getFilename, type FileSearchScore } from "@/lib/score-file-search";
|
|
31
|
+
|
|
32
|
+
/** Max results to display — prevents rendering thousands of matches */
|
|
33
|
+
const MAX_RESULTS = 100;
|
|
29
34
|
|
|
30
35
|
interface CommandItem {
|
|
31
36
|
id: string;
|
|
@@ -37,6 +42,8 @@ interface CommandItem {
|
|
|
37
42
|
group: "action" | "file" | "fs" | "db";
|
|
38
43
|
connectionColor?: string | null;
|
|
39
44
|
shortcut?: string;
|
|
45
|
+
/** True if gitignored — rendered with muted style for visual cue */
|
|
46
|
+
isIgnored?: boolean;
|
|
40
47
|
}
|
|
41
48
|
|
|
42
49
|
const isMac = typeof navigator !== "undefined" && /Mac|iPhone|iPad/.test(navigator.userAgent);
|
|
@@ -108,6 +115,7 @@ const fsCache = new Map<string, string[]>();
|
|
|
108
115
|
|
|
109
116
|
export function CommandPalette({ open, onClose, initialQuery = "" }: { open: boolean; onClose: () => void; initialQuery?: string }) {
|
|
110
117
|
const [query, setQuery] = useState("");
|
|
118
|
+
const deferredQuery = useDeferredValue(query);
|
|
111
119
|
const [selectedIdx, setSelectedIdx] = useState(0);
|
|
112
120
|
const [fsFiles, setFsFiles] = useState<string[]>([]);
|
|
113
121
|
const [fsLoading, setFsLoading] = useState(false);
|
|
@@ -185,6 +193,29 @@ export function CommandPalette({ open, onClose, initialQuery = "" }: { open: boo
|
|
|
185
193
|
{ id: "postgres", label: "PostgreSQL", icon: Database, action: openNewTab("postgres", "PostgreSQL"), keywords: "database pg sql query", group: "action" },
|
|
186
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")) },
|
|
187
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
|
+
},
|
|
188
219
|
{
|
|
189
220
|
id: "settings", label: "Settings", icon: Settings,
|
|
190
221
|
action: () => {
|
|
@@ -240,6 +271,8 @@ export function CommandPalette({ open, onClose, initialQuery = "" }: { open: boo
|
|
|
240
271
|
icon: FileCode,
|
|
241
272
|
group: "file" as const,
|
|
242
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,
|
|
243
276
|
action: () => {
|
|
244
277
|
openTab({
|
|
245
278
|
type: "editor",
|
|
@@ -305,11 +338,29 @@ export function CommandPalette({ open, onClose, initialQuery = "" }: { open: boo
|
|
|
305
338
|
[actionCommands, fileCommands],
|
|
306
339
|
);
|
|
307
340
|
|
|
341
|
+
/**
|
|
342
|
+
* Precomputed lowercase search index — avoids re-allocating thousands of
|
|
343
|
+
* lowercased strings per keystroke. Recomputed only when allCommands changes.
|
|
344
|
+
*/
|
|
345
|
+
const searchIndex = useMemo(() => {
|
|
346
|
+
return allCommands.map((cmd) => {
|
|
347
|
+
const path = cmd.keywords ?? cmd.label;
|
|
348
|
+
const pathLower = path.toLowerCase();
|
|
349
|
+
return {
|
|
350
|
+
cmd,
|
|
351
|
+
filenameLower: getFilename(pathLower),
|
|
352
|
+
pathLower,
|
|
353
|
+
labelLen: cmd.label.length,
|
|
354
|
+
depth: path.split("/").length,
|
|
355
|
+
};
|
|
356
|
+
});
|
|
357
|
+
}, [allCommands]);
|
|
358
|
+
|
|
308
359
|
const filtered = useMemo(() => {
|
|
309
360
|
// Path mode — search filesystem results using filename portion only
|
|
310
|
-
if (isPathQuery(
|
|
311
|
-
const lastSlash =
|
|
312
|
-
const fileFilter = lastSlash >= 0 ?
|
|
361
|
+
if (isPathQuery(deferredQuery)) {
|
|
362
|
+
const lastSlash = deferredQuery.lastIndexOf("/");
|
|
363
|
+
const fileFilter = lastSlash >= 0 ? deferredQuery.slice(lastSlash + 1).toLowerCase() : "";
|
|
313
364
|
if (!fileFilter) return fsCommands.slice(0, 50);
|
|
314
365
|
return fsCommands.filter((c) => {
|
|
315
366
|
const name = c.label.toLowerCase();
|
|
@@ -319,17 +370,18 @@ export function CommandPalette({ open, onClose, initialQuery = "" }: { open: boo
|
|
|
319
370
|
}
|
|
320
371
|
|
|
321
372
|
// Normal mode
|
|
322
|
-
if (!
|
|
373
|
+
if (!deferredQuery.trim()) return actionCommands;
|
|
374
|
+
const qLower = deferredQuery.toLowerCase();
|
|
323
375
|
const scored: Array<{ cmd: CommandItem; score: FileSearchScore }> = [];
|
|
324
|
-
for (const
|
|
325
|
-
const s =
|
|
326
|
-
if (s) scored.push({ cmd:
|
|
376
|
+
for (const entry of searchIndex) {
|
|
377
|
+
const s = scoreFileSearchFast(qLower, entry.filenameLower, entry.pathLower, entry.labelLen, entry.depth);
|
|
378
|
+
if (s) scored.push({ cmd: entry.cmd, score: s });
|
|
327
379
|
}
|
|
328
380
|
scored.sort((a, b) => compareScores(a.score, b.score));
|
|
329
|
-
const matched = scored.map((s) => s.cmd);
|
|
381
|
+
const matched = scored.slice(0, MAX_RESULTS).map((s) => s.cmd);
|
|
330
382
|
// Prepend DB results (already filtered server-side) when query is 2+ chars
|
|
331
|
-
return
|
|
332
|
-
}, [
|
|
383
|
+
return deferredQuery.trim().length >= 2 ? [...dbCommands, ...matched] : matched;
|
|
384
|
+
}, [searchIndex, actionCommands, fsCommands, dbCommands, deferredQuery]);
|
|
333
385
|
|
|
334
386
|
// Reset state when opening
|
|
335
387
|
useEffect(() => {
|
|
@@ -477,7 +529,8 @@ export function CommandPalette({ open, onClose, initialQuery = "" }: { open: boo
|
|
|
477
529
|
i === selectedIdx
|
|
478
530
|
? "bg-accent/15 text-text-primary"
|
|
479
531
|
: "text-text-secondary hover:bg-surface-elevated"
|
|
480
|
-
}`}
|
|
532
|
+
} ${cmd.isIgnored ? "opacity-60" : ""}`}
|
|
533
|
+
title={cmd.isIgnored ? "Gitignored file" : undefined}
|
|
481
534
|
>
|
|
482
535
|
<Icon className="size-4 shrink-0" />
|
|
483
536
|
<span className="truncate">{cmd.label}</span>
|
|
@@ -101,14 +101,14 @@ export function DraggableTab({
|
|
|
101
101
|
)}
|
|
102
102
|
>
|
|
103
103
|
<span
|
|
104
|
-
//
|
|
105
|
-
//
|
|
106
|
-
|
|
107
|
-
|
|
104
|
+
// Streaming: force amber (matches favicon streaming bg) so typing state is unmistakable
|
|
105
|
+
// regardless of tab active state. Otherwise inherits parent button's color (primary/text-secondary).
|
|
106
|
+
// Tag identity is now shown as a separate left-edge bar (see wrapper div below), not icon color.
|
|
107
|
+
className={cn("relative", isStreaming && "text-amber-500")}
|
|
108
108
|
>
|
|
109
109
|
<Icon className="size-4" />
|
|
110
110
|
{isStreaming ? (
|
|
111
|
-
// Messenger-style typing dots inside chat bubble — inherits current icon color
|
|
111
|
+
// Messenger-style typing dots inside chat bubble — inherits current icon color (amber while streaming)
|
|
112
112
|
<span aria-hidden className="absolute inset-0 flex items-center justify-center gap-[1.5px]">
|
|
113
113
|
<span className="tab-typing-dot size-[2px] rounded-full bg-current" />
|
|
114
114
|
<span className="tab-typing-dot size-[2px] rounded-full bg-current" style={{ animationDelay: "0.15s" }} />
|
|
@@ -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
|
})}
|