Import AITURK IDE 1.0.0-beta.1 from Hermes 63279301; preserve MIT license
This commit is contained in:
@@ -0,0 +1,66 @@
|
||||
/**
|
||||
* Command-palette contribution surface — `palette` data contributions become
|
||||
* rows in the ⌘K root list, same schema as every other area. Contributions
|
||||
* with an `action` id render that action's live keybind as their hotkey hint.
|
||||
*/
|
||||
|
||||
import { useContributions } from '@/contrib/react/use-contributions'
|
||||
import type { IconComponent } from '@/lib/icons'
|
||||
|
||||
export const PALETTE_AREA = 'palette'
|
||||
|
||||
/** Payload of a `palette` data contribution. */
|
||||
export interface PaletteContribution {
|
||||
id: string
|
||||
label: string
|
||||
/** Keybind action id — its live combo renders as the hotkey hint. */
|
||||
action?: string
|
||||
icon?: IconComponent
|
||||
keywords?: string[]
|
||||
run: () => void
|
||||
/**
|
||||
* Short note after the label — the live state the row acts on. A function
|
||||
* because contributions register once at boot while that state keeps moving;
|
||||
* the palette re-reads it on open.
|
||||
*/
|
||||
detail?: () => string
|
||||
/** `state` when running the row CHANGES what `detail` says. */
|
||||
detailVariant?: 'muted' | 'state'
|
||||
/** Leave the palette open after running — for rows you may run repeatedly. */
|
||||
keepOpen?: boolean
|
||||
}
|
||||
|
||||
/** Contributed palette rows, with stable render keys. */
|
||||
export function usePaletteContributions(): Array<PaletteContribution & { key: string }> {
|
||||
return useContributions(PALETTE_AREA)
|
||||
.map(c => ({ key: `${c.source ?? 'core'}:${c.id}`, ...(c.data as PaletteContribution) }))
|
||||
.filter(item => Boolean(item.label && item.run))
|
||||
}
|
||||
|
||||
/**
|
||||
* A binary setting as one palette row: `Toggle status bar` trailed by the live
|
||||
* state. The verb says what the row does, the note says where it stands —
|
||||
* neither alone is enough to act on.
|
||||
*
|
||||
* Rows keep the palette open: flipping a setting is the kind of thing you do
|
||||
* two or three of in a row, and the note updating in place is the receipt.
|
||||
*/
|
||||
export function paletteToggle(
|
||||
spec: Omit<PaletteContribution, 'detail' | 'detailVariant' | 'keepOpen' | 'run'> & {
|
||||
get: () => boolean
|
||||
set: (enabled: boolean) => void
|
||||
}
|
||||
) {
|
||||
const { get, keywords = [], set, ...rest } = spec
|
||||
|
||||
const data: PaletteContribution = {
|
||||
...rest,
|
||||
detail: () => (get() ? 'on' : 'off'),
|
||||
detailVariant: 'state',
|
||||
keepOpen: true,
|
||||
keywords: [...keywords, 'on', 'off', 'enable', 'disable'],
|
||||
run: () => set(!get())
|
||||
}
|
||||
|
||||
return { id: data.id, area: PALETTE_AREA, data }
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
/**
|
||||
* The regression that motivates this file: cmdk calls the root
|
||||
* `onValueChange` only in controlled mode (a set `value` prop). The palette
|
||||
* is uncontrolled, so a highlight preview wired to that prop never fires.
|
||||
* The watcher must read the cmdk store instead, which works in both modes.
|
||||
*/
|
||||
|
||||
import { render } from '@testing-library/react'
|
||||
import { Command } from 'cmdk'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { stubMenuDomApis, stubResizeObserver } from '@/test/jsdom'
|
||||
|
||||
import { HighlightWatcher } from './highlight-watcher'
|
||||
|
||||
stubResizeObserver()
|
||||
stubMenuDomApis()
|
||||
|
||||
const palette = (onValue: (value: string) => void, onRootValueChange?: (value: string) => void) => (
|
||||
<Command onValueChange={onRootValueChange}>
|
||||
<HighlightWatcher onValue={onValue} />
|
||||
<Command.List>
|
||||
<Command.Item value="alpha">Alpha</Command.Item>
|
||||
<Command.Item value="beta">Beta</Command.Item>
|
||||
</Command.List>
|
||||
</Command>
|
||||
)
|
||||
|
||||
describe('HighlightWatcher', () => {
|
||||
it('reports the highlight from the cmdk store in uncontrolled mode', () => {
|
||||
const onValue = vi.fn()
|
||||
|
||||
render(palette(onValue))
|
||||
|
||||
// cmdk auto-highlights the first item on mount.
|
||||
expect(onValue).toHaveBeenCalledWith('alpha')
|
||||
})
|
||||
|
||||
it('covers the gap: the root onValueChange stays silent in uncontrolled mode', () => {
|
||||
const onValue = vi.fn()
|
||||
const onRootValueChange = vi.fn()
|
||||
|
||||
render(palette(onValue, onRootValueChange))
|
||||
|
||||
// The store-based watcher fires. The prop the first implementation used
|
||||
// does not. If cmdk starts to fire it in uncontrolled mode, the watcher
|
||||
// becomes removable — this assertion is the signal.
|
||||
expect(onValue).toHaveBeenCalledWith('alpha')
|
||||
expect(onRootValueChange).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,31 @@
|
||||
/**
|
||||
* Reports the cmdk highlight to the palette.
|
||||
*
|
||||
* cmdk calls the root `onValueChange` only in controlled mode (when the
|
||||
* `value` prop is set). This palette is uncontrolled, so that prop never
|
||||
* fires. This watcher subscribes to the cmdk store with `useCommandState`,
|
||||
* which works in both modes, and reports each highlight change.
|
||||
*/
|
||||
|
||||
import { useCommandState } from 'cmdk'
|
||||
import { useEffect, useRef } from 'react'
|
||||
|
||||
interface HighlightWatcherProps {
|
||||
/** Receives the cmdk value of each newly highlighted row. */
|
||||
onValue: (value: string) => void
|
||||
}
|
||||
|
||||
export function HighlightWatcher({ onValue }: HighlightWatcherProps): null {
|
||||
const value = useCommandState(state => state.value)
|
||||
|
||||
// Pin the callback so the effect fires on highlight changes only. The
|
||||
// parent passes a fresh closure on every render.
|
||||
const onValueRef = useRef(onValue)
|
||||
onValueRef.current = onValue
|
||||
|
||||
useEffect(() => {
|
||||
onValueRef.current(value)
|
||||
}, [value])
|
||||
|
||||
return null
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,148 @@
|
||||
/**
|
||||
* Cmd-K "Install theme…" page.
|
||||
*
|
||||
* Browses the VS Code Marketplace for color themes: an empty query shows the
|
||||
* most-installed themes, typing runs a live (debounced) search against the
|
||||
* Marketplace. Selecting a row downloads + converts + installs it via the same
|
||||
* pipeline as the settings importer, then activates it — and stays open so the
|
||||
* user can grab several.
|
||||
*/
|
||||
|
||||
import { useStore } from '@nanostores/react'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { useState } from 'react'
|
||||
|
||||
import { StatusRow } from '@/app/command-palette/status-row'
|
||||
import { HUD_ITEM, HUD_TEXT } from '@/app/floating-hud'
|
||||
import { useDebounced } from '@/app/hooks/use-debounced'
|
||||
import type { DesktopMarketplaceSearchItem } from '@/global'
|
||||
import { useI18n } from '@/i18n'
|
||||
import { triggerHaptic } from '@/lib/haptics'
|
||||
import { Check, Download, Loader2, Palette } from '@/lib/icons'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { installVscodeThemeFromMarketplace } from '@/themes/install'
|
||||
import { $marketplaceInstalls } from '@/themes/user-themes'
|
||||
|
||||
const compactNumber = new Intl.NumberFormat(undefined, { notation: 'compact', maximumFractionDigits: 1 })
|
||||
|
||||
interface MarketplaceThemePageProps {
|
||||
search: string
|
||||
/** Activate a freshly installed theme by slug. */
|
||||
onPickTheme: (name: string) => void
|
||||
}
|
||||
|
||||
export function MarketplaceThemePage({ search, onPickTheme }: MarketplaceThemePageProps) {
|
||||
const { t } = useI18n()
|
||||
const copy = t.commandCenter.installTheme
|
||||
const debouncedSearch = useDebounced(search.trim(), 300)
|
||||
const installs = useStore($marketplaceInstalls)
|
||||
const [installingId, setInstallingId] = useState<string | null>(null)
|
||||
const [installError, setInstallError] = useState<string | null>(null)
|
||||
|
||||
const query = useQuery({
|
||||
queryKey: ['marketplace-themes', debouncedSearch],
|
||||
queryFn: () => window.hermesDesktop?.themes?.searchMarketplace(debouncedSearch) ?? Promise.resolve([]),
|
||||
staleTime: 5 * 60 * 1000
|
||||
})
|
||||
|
||||
// Already installed → just re-activate it; never re-download what we have.
|
||||
const select = (item: DesktopMarketplaceSearchItem) => {
|
||||
const owned = installs.get(item.extensionId)
|
||||
|
||||
if (owned) {
|
||||
triggerHaptic('crisp')
|
||||
onPickTheme(owned.name)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
void install(item)
|
||||
}
|
||||
|
||||
const install = async (item: DesktopMarketplaceSearchItem) => {
|
||||
if (installingId) {
|
||||
return
|
||||
}
|
||||
|
||||
setInstallingId(item.extensionId)
|
||||
setInstallError(null)
|
||||
|
||||
try {
|
||||
const theme = await installVscodeThemeFromMarketplace(item.extensionId)
|
||||
|
||||
triggerHaptic('crisp')
|
||||
onPickTheme(theme.name)
|
||||
} catch (error) {
|
||||
setInstallError(error instanceof Error ? error.message : copy.error)
|
||||
} finally {
|
||||
setInstallingId(null)
|
||||
}
|
||||
}
|
||||
|
||||
if (query.isLoading) {
|
||||
return <StatusRow icon={<Loader2 className="size-3.5 animate-spin" />} text={copy.loading} />
|
||||
}
|
||||
|
||||
if (query.isError) {
|
||||
return <StatusRow text={copy.error} tone="error" />
|
||||
}
|
||||
|
||||
const results = query.data ?? []
|
||||
|
||||
if (results.length === 0) {
|
||||
return <StatusRow text={copy.empty} />
|
||||
}
|
||||
|
||||
return (
|
||||
<div role="listbox">
|
||||
{installError && <p className="px-2 pb-1 pt-1.5 text-[0.6875rem] text-(--ui-red)">{installError}</p>}
|
||||
{results.map(item => {
|
||||
const busy = installingId === item.extensionId
|
||||
const done = installs.has(item.extensionId)
|
||||
|
||||
return (
|
||||
<button
|
||||
className={cn(
|
||||
'flex w-full items-start rounded-md text-left transition-colors hover:bg-(--chrome-action-hover) disabled:opacity-60 aria-disabled:opacity-60',
|
||||
HUD_ITEM,
|
||||
HUD_TEXT
|
||||
)}
|
||||
disabled={Boolean(installingId) && !busy}
|
||||
key={item.extensionId}
|
||||
onClick={() => select(item)}
|
||||
onMouseDown={event => event.preventDefault()}
|
||||
role="option"
|
||||
type="button"
|
||||
>
|
||||
<Palette className="mt-0.5 size-3.5 shrink-0 text-muted-foreground" />
|
||||
<span className="flex min-w-0 flex-col">
|
||||
<span className="truncate font-medium">{item.displayName}</span>
|
||||
<span className="truncate text-[0.6875rem] text-muted-foreground/80">
|
||||
{item.publisher}
|
||||
{item.installs > 0 ? ` · ${copy.installs(compactNumber.format(item.installs))}` : ''}
|
||||
</span>
|
||||
</span>
|
||||
<span className="ml-auto mt-0.5 flex shrink-0 items-center gap-1 text-[0.6875rem] text-muted-foreground">
|
||||
{busy ? (
|
||||
<>
|
||||
<Loader2 className="size-3 animate-spin" />
|
||||
{copy.installing}
|
||||
</>
|
||||
) : done ? (
|
||||
<>
|
||||
<Check className="size-3 text-(--ui-green)" />
|
||||
{copy.installed}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Download className="size-3" />
|
||||
{copy.install}
|
||||
</>
|
||||
)}
|
||||
</span>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,201 @@
|
||||
/**
|
||||
* Cmd-K "Pets…" page — browse the petdex gallery, adopt/switch, toggle off.
|
||||
*
|
||||
* A thin view over the `pet-gallery` store: it subscribes to the shared atoms
|
||||
* and calls the store's actions. The store owns fetching, caching, the thumb
|
||||
* cache, and optimistic mutations, so reopening this page is instant and a
|
||||
* toggle never re-pulls the network gallery.
|
||||
*/
|
||||
|
||||
import { useStore } from '@nanostores/react'
|
||||
import { useEffect, useMemo } from 'react'
|
||||
|
||||
import { StatusRow } from '@/app/command-palette/status-row'
|
||||
import { HUD_ITEM, HUD_TEXT } from '@/app/floating-hud'
|
||||
import { useGatewayRequest } from '@/app/gateway/hooks/use-gateway-request'
|
||||
import { PetThumb } from '@/components/pet/pet-thumb'
|
||||
import { useI18n } from '@/i18n'
|
||||
import { triggerHaptic } from '@/lib/haptics'
|
||||
import { Check, Egg, Loader2, PawPrint } from '@/lib/icons'
|
||||
import { cn } from '@/lib/utils'
|
||||
import {
|
||||
$petBusy,
|
||||
$petGallery,
|
||||
$petGalleryError,
|
||||
$petGalleryStatus,
|
||||
adoptPet,
|
||||
loadPetGallery,
|
||||
loadPetThumb,
|
||||
rankedGalleryPets,
|
||||
setPetEnabled
|
||||
} from '@/store/pet-gallery'
|
||||
|
||||
interface PetPalettePageProps {
|
||||
search: string
|
||||
/** Navigate to the "generate a pet" page (rendered as a header action). */
|
||||
onGenerate?: () => void
|
||||
}
|
||||
|
||||
export function PetPalettePage({ search, onGenerate }: PetPalettePageProps) {
|
||||
const { t } = useI18n()
|
||||
const copy = t.commandCenter.pets
|
||||
const { requestGateway } = useGatewayRequest()
|
||||
|
||||
const gallery = useStore($petGallery)
|
||||
const status = useStore($petGalleryStatus)
|
||||
const error = useStore($petGalleryError)
|
||||
const busy = useStore($petBusy)
|
||||
|
||||
useEffect(() => {
|
||||
void loadPetGallery(requestGateway)
|
||||
}, [requestGateway])
|
||||
|
||||
const enabled = gallery?.enabled ?? false
|
||||
const active = gallery?.active ?? ''
|
||||
|
||||
const shown = useMemo(() => rankedGalleryPets(gallery, search).slice(0, 50), [gallery, search])
|
||||
|
||||
const adopt = (slug: string) => {
|
||||
void adoptPet(requestGateway, slug, copy.adoptFailed).then(ok => ok && triggerHaptic('crisp'))
|
||||
}
|
||||
|
||||
if (status === 'loading' && !gallery) {
|
||||
return <StatusRow icon={<Loader2 className="size-3.5 animate-spin" />} text={copy.loading} />
|
||||
}
|
||||
|
||||
if (status === 'stale') {
|
||||
return <StatusRow text={copy.staleBackend} tone="error" />
|
||||
}
|
||||
|
||||
if (!gallery?.pets.length && error) {
|
||||
return <StatusRow text={error} tone="error" />
|
||||
}
|
||||
|
||||
const mutating = Boolean(busy)
|
||||
|
||||
return (
|
||||
<div role="listbox">
|
||||
{onGenerate && (
|
||||
<button
|
||||
className={cn(
|
||||
'flex w-full items-center gap-2 rounded-md text-left text-foreground transition-colors hover:bg-(--chrome-action-hover)',
|
||||
HUD_ITEM,
|
||||
HUD_TEXT
|
||||
)}
|
||||
onClick={onGenerate}
|
||||
onMouseDown={event => event.preventDefault()}
|
||||
type="button"
|
||||
>
|
||||
<span className="flex size-8 shrink-0 items-center justify-center rounded-md bg-(--chrome-action-hover)">
|
||||
<Egg className="size-4" />
|
||||
</span>
|
||||
<span className="font-medium">{t.commandCenter.generatePet.title}</span>
|
||||
</button>
|
||||
)}
|
||||
|
||||
{error && <p className="px-2 pb-1 pt-1.5 text-[0.6875rem] text-(--ui-red)">{error}</p>}
|
||||
|
||||
{shown.length === 0 ? (
|
||||
<StatusRow text={copy.empty} />
|
||||
) : (
|
||||
shown.map(pet => {
|
||||
const isActive = enabled && pet.slug === active
|
||||
const isBusy = busy === pet.slug
|
||||
|
||||
return (
|
||||
<button
|
||||
className={cn(
|
||||
'flex w-full items-center gap-2 rounded-md text-left transition-colors hover:bg-(--chrome-action-hover) disabled:opacity-60',
|
||||
HUD_ITEM,
|
||||
HUD_TEXT,
|
||||
isActive && 'bg-(--chrome-action-hover)/70'
|
||||
)}
|
||||
disabled={mutating && !isBusy}
|
||||
key={pet.slug}
|
||||
onClick={() => adopt(pet.slug)}
|
||||
onMouseDown={event => event.preventDefault()}
|
||||
role="option"
|
||||
type="button"
|
||||
>
|
||||
<PetThumb
|
||||
alt={pet.displayName}
|
||||
load={(slug, url) => loadPetThumb(requestGateway, slug, url)}
|
||||
size={32}
|
||||
slug={pet.slug}
|
||||
url={pet.spritesheetUrl}
|
||||
/>
|
||||
<span className="flex min-w-0 flex-col">
|
||||
<span className="flex items-center gap-1.5">
|
||||
<span className="truncate font-medium">{pet.displayName}</span>
|
||||
{pet.generated && (
|
||||
<span className="shrink-0 rounded-full bg-primary/15 px-1.5 py-px text-[0.625rem] font-medium text-primary">
|
||||
{copy.generatedTag}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
<span className="truncate text-[0.6875rem] text-muted-foreground/80">
|
||||
{pet.slug}
|
||||
{pet.installed ? ` · ${copy.installed}` : ''}
|
||||
</span>
|
||||
</span>
|
||||
<span className="ml-auto flex shrink-0 items-center text-[0.6875rem] text-muted-foreground">
|
||||
{isBusy ? (
|
||||
<Loader2 className="size-3 animate-spin" />
|
||||
) : isActive ? (
|
||||
<Check className="size-3.5 text-foreground" />
|
||||
) : null}
|
||||
</span>
|
||||
</button>
|
||||
)
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Single on/off toggle, rendered inline on the palette's search row (see
|
||||
* `CommandInput`'s `right` slot). The paw lights up when pets are on. Reads the
|
||||
* same shared gallery atoms, so it stays in sync with the list below.
|
||||
*/
|
||||
export function PetInlineToggle() {
|
||||
const { t } = useI18n()
|
||||
const copy = t.commandCenter.pets
|
||||
const { requestGateway } = useGatewayRequest()
|
||||
const gallery = useStore($petGallery)
|
||||
const busy = useStore($petBusy)
|
||||
|
||||
if (!gallery) {
|
||||
return null
|
||||
}
|
||||
|
||||
const enabled = gallery.enabled
|
||||
|
||||
const toggle = () => {
|
||||
void setPetEnabled(requestGateway, !enabled, {
|
||||
noneAvailable: copy.noneAvailable,
|
||||
fallback: copy.toggleFailed(!enabled)
|
||||
}).then(ok => ok && triggerHaptic('crisp'))
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
aria-label={enabled ? copy.turnOff : copy.turnOn}
|
||||
aria-pressed={enabled}
|
||||
className={cn(
|
||||
'flex shrink-0 items-center justify-center rounded-md p-1.5 transition-colors disabled:opacity-50',
|
||||
enabled
|
||||
? 'bg-(--chrome-action-hover) text-foreground'
|
||||
: 'text-muted-foreground hover:bg-(--chrome-action-hover)/60'
|
||||
)}
|
||||
disabled={Boolean(busy)}
|
||||
onClick={toggle}
|
||||
// Don't steal focus from the search input on click.
|
||||
onMouseDown={event => event.preventDefault()}
|
||||
title={enabled ? copy.turnOff : copy.turnOn}
|
||||
type="button"
|
||||
>
|
||||
{busy ? <Loader2 className="size-4 animate-spin" /> : <PawPrint className="size-4" />}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import type React from 'react'
|
||||
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
/** Centered loading / empty / error row for a Cmd-K page's result list. */
|
||||
export function StatusRow({ icon, text, tone }: { icon?: React.ReactNode; text: string; tone?: 'error' }) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'flex items-center justify-center gap-2 px-2 py-6 text-xs',
|
||||
tone === 'error' ? 'text-(--ui-red)' : 'text-muted-foreground'
|
||||
)}
|
||||
>
|
||||
{icon}
|
||||
{text}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user