Skip to content

Commit 0ff8550

Browse files
NamesMTelianiva
andcommitted
refactor(marketplace): some UI adjustments (#13)
* refactor(marketplace): add installed tabs * fix: missing settings button * refactor(marketplace): better card UI * refactor(marketplace): better error message for sources * tests(marketplace): item card and source config * refactor(marketplace): colocate local states * refactor(marketplace): simplify tabs * test: marketplace view --------- Co-authored-by: elianiva <[email protected]>
1 parent c5f13bf commit 0ff8550

28 files changed

+1888
-550
lines changed

src/i18n/locales/en/marketplace.json

Lines changed: 8 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -27,11 +27,6 @@
2727
"installButton": "Install",
2828
"cancelButton": "Cancel"
2929
},
30-
"install-sidebar": {
31-
"title": "Install {{itemName}}",
32-
"installButton": "Install",
33-
"cancelButton": "Cancel"
34-
},
3530
"filters": {
3631
"search": {
3732
"placeholder": "Search marketplace..."
@@ -78,19 +73,20 @@
7873
"current": {
7974
"title": "Current Sources",
8075
"empty": "No marketplace sources added yet.",
81-
"emptyHint": "Add a source above to browse marketplace items."
82-
},
83-
"current": {
76+
"emptyHint": "Add a source above to browse marketplace items.",
8477
"refresh": "Refresh source",
8578
"remove": "Remove source"
8679
}
8780
},
88-
"tabs": {
89-
"browse": "Browse",
90-
"sources": "Sources"
91-
},
9281
"title": "Marketplace"
9382
},
83+
"done": "Done",
84+
"refresh": "Refresh",
85+
"tabs": {
86+
"installed": "Installed",
87+
"browse": "Browse",
88+
"settings": "Settings"
89+
},
9490
"items": {
9591
"refresh": {
9692
"refreshing": "Refreshing marketplace items..."

webview-ui/src/components/marketplace/MarketplaceListView.tsx

Lines changed: 19 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import * as React from "react"
12
import { Input } from "@/components/ui/input"
23
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
34
import { Button } from "@/components/ui/button"
@@ -13,24 +14,23 @@ export interface MarketplaceListViewProps {
1314
stateManager: MarketplaceViewStateManager
1415
allTags: string[]
1516
filteredTags: string[]
16-
tagSearch: string
17-
setTagSearch: (value: string) => void
18-
isTagPopoverOpen: boolean
19-
setIsTagPopoverOpen: (value: boolean) => void
17+
showInstalledOnly?: boolean
2018
}
2119

2220
export function MarketplaceListView({
2321
stateManager,
2422
allTags,
2523
filteredTags,
26-
tagSearch,
27-
setTagSearch,
28-
isTagPopoverOpen,
29-
setIsTagPopoverOpen,
24+
showInstalledOnly = false,
3025
}: MarketplaceListViewProps) {
3126
const [state, manager] = useStateManager(stateManager)
3227
const { t } = useAppTranslation()
33-
const items = state.displayItems || []
28+
const [isTagPopoverOpen, setIsTagPopoverOpen] = React.useState(false)
29+
const [tagSearch, setTagSearch] = React.useState("")
30+
const allItems = state.displayItems || []
31+
const items = showInstalledOnly
32+
? allItems.filter((item) => state.installedMetadata.project[item.id] || state.installedMetadata.global[item.id])
33+
: allItems
3434
const isEmpty = items.length === 0
3535

3636
return (
@@ -142,7 +142,7 @@ export function MarketplaceListView({
142142
},
143143
})
144144
}
145-
className="shadow-none bg-vscode-input-background px-2">
145+
className="shadow-none bg-vscode-dropdown-background px-2">
146146
{state.sortConfig.order === "asc" ? "↑" : "↓"}
147147
</Button>
148148
</div>
@@ -176,7 +176,7 @@ export function MarketplaceListView({
176176
)}
177177
</div>
178178

179-
<Popover open={isTagPopoverOpen} onOpenChange={setIsTagPopoverOpen}>
179+
<Popover open={isTagPopoverOpen} onOpenChange={(open) => setIsTagPopoverOpen(open)}>
180180
<PopoverTrigger asChild>
181181
<Button
182182
variant="combobox"
@@ -193,7 +193,9 @@ export function MarketplaceListView({
193193
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
194194
</Button>
195195
</PopoverTrigger>
196-
<PopoverContent className="w-[var(--radix-popover-trigger-width)] p-0">
196+
<PopoverContent
197+
className="w-[var(--radix-popover-trigger-width)] p-0"
198+
onClick={(e) => e.stopPropagation()}>
197199
<Command>
198200
<div className="relative">
199201
<CommandInput
@@ -238,7 +240,10 @@ export function MarketplaceListView({
238240
}}
239241
data-selected={state.filters.tags.includes(tag)}
240242
className="grid grid-cols-[1rem_1fr] gap-2 cursor-pointer text-sm capitalize"
241-
onMouseDown={(e) => e.preventDefault()}>
243+
onMouseDown={(e) => {
244+
e.stopPropagation()
245+
e.preventDefault()
246+
}}>
242247
{state.filters.tags.includes(tag) ? (
243248
<span className="codicon codicon-check" />
244249
) : (
@@ -265,7 +270,7 @@ export function MarketplaceListView({
265270
</div>
266271
</div>
267272

268-
{state.isFetching && (
273+
{state.isFetching && isEmpty && (
269274
<div className="flex flex-col items-center justify-center h-64 text-vscode-descriptionForeground animate-fade-in">
270275
<div className="animate-spin mb-4">
271276
<span className="codicon codicon-sync text-3xl"></span>

webview-ui/src/components/marketplace/MarketplaceSourcesConfigView.tsx

Lines changed: 147 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import { Button } from "@/components/ui/button"
66
import { Input } from "@/components/ui/input"
77
import { Checkbox } from "@/components/ui/checkbox"
88
import { useStateManager } from "./useStateManager"
9-
import { validateSource } from "@roo/shared/MarketplaceValidation"
9+
import { validateSource, ValidationError } from "@roo/shared/MarketplaceValidation"
1010
import { cn } from "@src/lib/utils"
1111

1212
export interface MarketplaceSourcesConfigProps {
@@ -19,18 +19,90 @@ export function MarketplaceSourcesConfig({ stateManager }: MarketplaceSourcesCon
1919
const [newSourceUrl, setNewSourceUrl] = useState("")
2020
const [newSourceName, setNewSourceName] = useState("")
2121
const [error, setError] = useState("")
22+
const [fieldErrors, setFieldErrors] = useState<{
23+
name?: string
24+
url?: string
25+
}>({})
26+
27+
// Check if name contains emoji characters
28+
const containsEmoji = (str: string): boolean => {
29+
// Simple emoji detection using common emoji ranges
30+
// This avoids using Unicode property escapes which require ES2018+
31+
return (
32+
/[\ud83c\ud83d\ud83e][\ud000-\udfff]/.test(str) || // Common emoji surrogate pairs
33+
/[\u2600-\u27BF]/.test(str) || // Misc symbols and pictographs
34+
/[\u2300-\u23FF]/.test(str) || // Miscellaneous Technical
35+
/[\u2700-\u27FF]/.test(str) || // Dingbats
36+
/[\u2B50\u2B55]/.test(str) || // Star, Circle
37+
/[\u203C\u2049\u20E3\u2122\u2139\u2194-\u2199\u21A9\u21AA]/.test(str)
38+
) // Punctuation
39+
}
40+
41+
// Validate input fields without submitting
42+
const validateFields = () => {
43+
const newErrors: { name?: string; url?: string } = {}
44+
45+
// Validate name if provided
46+
if (newSourceName) {
47+
if (newSourceName.length > 20) {
48+
newErrors.name = t("marketplace:sources.errors.nameTooLong")
49+
} else if (containsEmoji(newSourceName)) {
50+
newErrors.name = t("marketplace:sources.errors.emojiName")
51+
} else {
52+
// Check for duplicate names
53+
const hasDuplicateName = state.sources.some(
54+
(source) => source.name && source.name.toLowerCase() === newSourceName.toLowerCase(),
55+
)
56+
if (hasDuplicateName) {
57+
newErrors.name = t("marketplace:sources.errors.duplicateName")
58+
}
59+
}
60+
}
61+
62+
// Validate URL
63+
if (!newSourceUrl.trim()) {
64+
newErrors.url = t("marketplace:sources.errors.emptyUrl")
65+
} else {
66+
// Check for duplicate URLs
67+
const hasDuplicateUrl = state.sources.some(
68+
(source) => source.url.toLowerCase().trim() === newSourceUrl.toLowerCase().trim(),
69+
)
70+
if (hasDuplicateUrl) {
71+
newErrors.url = t("marketplace:sources.errors.duplicateUrl")
72+
}
73+
}
74+
75+
setFieldErrors(newErrors)
76+
return Object.keys(newErrors).length === 0
77+
}
2278

2379
const handleAddSource = () => {
2480
const MAX_SOURCES = 10
2581
if (state.sources.length >= MAX_SOURCES) {
2682
setError(t("marketplace:sources.errors.maxSources", { max: MAX_SOURCES }))
2783
return
2884
}
85+
86+
// Clear previous errors
87+
setError("")
88+
89+
// Perform quick validation first
90+
if (!validateFields()) {
91+
// If we have specific field errors, show the first one as the main error
92+
if (fieldErrors.url) {
93+
setError(fieldErrors.url)
94+
} else if (fieldErrors.name) {
95+
setError(fieldErrors.name)
96+
}
97+
return
98+
}
99+
29100
const sourceToValidate: MarketplaceSource = {
30-
url: newSourceUrl,
31-
name: newSourceName || undefined,
101+
url: newSourceUrl.trim(),
102+
name: newSourceName.trim() || undefined,
32103
enabled: true,
33104
}
105+
34106
const validationErrors = validateSource(sourceToValidate, state.sources)
35107
if (validationErrors.length > 0) {
36108
const errorMessages: Record<string, string> = {
@@ -42,7 +114,34 @@ export function MarketplaceSourcesConfig({ stateManager }: MarketplaceSourcesCon
42114
"name:nonvisible": "marketplace:sources.errors.nonVisibleCharsName",
43115
"name:duplicate": "marketplace:sources.errors.duplicateName",
44116
}
45-
const error = validationErrors[0]
117+
118+
// Group errors by field for better user feedback
119+
const fieldErrorMap: Record<string, ValidationError[]> = {}
120+
for (const error of validationErrors) {
121+
if (!fieldErrorMap[error.field]) {
122+
fieldErrorMap[error.field] = []
123+
}
124+
fieldErrorMap[error.field].push(error)
125+
}
126+
127+
// Update field-specific errors
128+
const newFieldErrors: { name?: string; url?: string } = {}
129+
if (fieldErrorMap.name) {
130+
const error = fieldErrorMap.name[0]
131+
const errorKey = `name:${error.message.toLowerCase().split(" ")[0]}`
132+
newFieldErrors.name = t(errorMessages[errorKey] || error.message)
133+
}
134+
135+
if (fieldErrorMap.url) {
136+
const error = fieldErrorMap.url[0]
137+
const errorKey = `url:${error.message.toLowerCase().split(" ")[0]}`
138+
newFieldErrors.url = t(errorMessages[errorKey] || error.message)
139+
}
140+
141+
setFieldErrors(newFieldErrors)
142+
143+
// Set the main error message (prioritize URL errors)
144+
const error = fieldErrorMap.url?.[0] || validationErrors[0]
46145
const errorKey = `${error.field}:${error.message.toLowerCase().split(" ")[0]}`
47146
setError(t(errorMessages[errorKey] || "marketplace:sources.errors.invalidGitUrl"))
48147
return
@@ -97,16 +196,40 @@ export function MarketplaceSourcesConfig({ stateManager }: MarketplaceSourcesCon
97196
onChange={(e) => {
98197
setNewSourceName(e.target.value.slice(0, 20))
99198
setError("")
199+
setFieldErrors((prev) => ({ ...prev, name: undefined }))
200+
201+
// Live validation for emojis and length
202+
const value = e.target.value
203+
if (value && containsEmoji(value)) {
204+
setFieldErrors((prev) => ({
205+
...prev,
206+
name: t("marketplace:sources.errors.emojiName"),
207+
}))
208+
} else if (value.length >= 20) {
209+
setFieldErrors((prev) => ({
210+
...prev,
211+
name: t("marketplace:sources.errors.nameTooLong"),
212+
}))
213+
}
100214
}}
101215
maxLength={20}
102-
className="pl-10"
216+
className={cn("pl-10", {
217+
"border-red-500 focus-visible:ring-red-500": fieldErrors.name,
218+
})}
219+
onBlur={() => validateFields()}
103220
/>
104221
<span className="absolute left-3 top-1/2 -translate-y-1/2 text-vscode-descriptionForeground">
105222
<span className="codicon codicon-tag"></span>
106223
</span>
107-
<span className="absolute right-3 top-1/2 -translate-y-1/2 text-xs text-vscode-descriptionForeground">
224+
<span
225+
className={cn(
226+
"absolute right-3 top-1/2 -translate-y-1/2 text-xs",
227+
newSourceName.length >= 18 ? "text-amber-500" : "text-vscode-descriptionForeground",
228+
newSourceName.length >= 20 ? "text-red-500" : "",
229+
)}>
108230
{newSourceName.length}/20
109231
</span>
232+
{fieldErrors.name && <p className="text-xs text-red-500 mt-1 mb-0">{fieldErrors.name}</p>}
110233
</div>
111234
<div className="relative">
112235
<Input
@@ -116,12 +239,25 @@ export function MarketplaceSourcesConfig({ stateManager }: MarketplaceSourcesCon
116239
onChange={(e) => {
117240
setNewSourceUrl(e.target.value)
118241
setError("")
242+
setFieldErrors((prev) => ({ ...prev, url: undefined }))
243+
244+
// Live validation for empty URL
245+
if (!e.target.value.trim()) {
246+
setFieldErrors((prev) => ({
247+
...prev,
248+
url: t("marketplace:sources.errors.emptyUrl"),
249+
}))
250+
}
119251
}}
120-
className="pl-10"
252+
className={cn("pl-10", {
253+
"border-red-500 focus-visible:ring-red-500": fieldErrors.url,
254+
})}
255+
onBlur={() => validateFields()}
121256
/>
122257
<span className="absolute left-3 top-1/2 -translate-y-1/2 text-vscode-descriptionForeground">
123258
<span className="codicon codicon-link"></span>
124259
</span>
260+
{fieldErrors.url && <p className="text-xs text-red-500 mt-1 mb-0">{fieldErrors.url}</p>}
125261
</div>
126262
<p className="text-xs text-vscode-descriptionForeground m-0">
127263
{t("marketplace:sources.add.urlFormats")}
@@ -135,7 +271,10 @@ export function MarketplaceSourcesConfig({ stateManager }: MarketplaceSourcesCon
135271
</p>
136272
</div>
137273
)}
138-
<Button onClick={handleAddSource} className="mt-2 w-full shadow-none border-none">
274+
<Button
275+
onClick={handleAddSource}
276+
className="mt-2 w-full shadow-none border-none"
277+
disabled={!!fieldErrors.name || !!fieldErrors.url || !newSourceUrl.trim()}>
139278
<span className="codicon codicon-add"></span>
140279
{t("marketplace:sources.add.button")}
141280
</Button>

0 commit comments

Comments
 (0)