Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ interface RegistryCommand extends GlobalCommand {

interface GlobalCommandsContextValue {
register: (commands: GlobalCommand[]) => () => void
invoke: (id: string) => boolean
}

const GlobalCommandsContext = createContext<GlobalCommandsContextValue | null>(null)
Expand Down Expand Up @@ -142,11 +143,39 @@ export function GlobalCommandsProvider({ children }: { children: ReactNode }) {
return () => window.removeEventListener('keydown', onKeyDown, { capture: true })
}, [isMac, router])

const value = useMemo<GlobalCommandsContextValue>(() => ({ register }), [register])
const invoke = useCallback((id: string): boolean => {
const cmd = registryRef.current.get(id)
if (!cmd) return false
try {
cmd.handler(new KeyboardEvent('keydown'))
} catch (err) {
logger.error('Global command handler threw', { id, err })
}
return true
}, [])

const value = useMemo<GlobalCommandsContextValue>(
() => ({ register, invoke }),
[register, invoke]
)

return <GlobalCommandsContext.Provider value={value}>{children}</GlobalCommandsContext.Provider>
}

/**
* Returns a function that runs a registered global command by id, mirroring its
* keyboard shortcut exactly. Returns `false` when no command with that id is
* currently registered (e.g. a workflow-only command invoked off-canvas), so
* callers can offer the action safely without knowing what is mounted.
*/
export function useInvokeGlobalCommand(): (id: string) => boolean {
const ctx = useContext(GlobalCommandsContext)
if (!ctx) {
throw new Error('useInvokeGlobalCommand must be used within GlobalCommandsProvider')
}
return ctx.invoke
}

export function useRegisterGlobalCommands(commands: GlobalCommand[] | (() => GlobalCommand[])) {
const ctx = useContext(GlobalCommandsContext)
if (!ctx) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,54 @@ import { Command } from 'cmdk'
import { File, Workflow } from '@/components/emcn/icons'
import { cn } from '@/lib/core/utils/cn'
import type { CommandItemProps } from '@/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/utils'
import { COMMAND_ITEM_CLASSNAME } from '@/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/utils'
import {
COMMAND_ITEM_CLASSNAME,
fuzzyMatch,
} from '@/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/utils'

interface Segment {
text: string
hit: boolean
}

function buildSegments(text: string, positions: readonly number[]): Segment[] {
const hits = new Set(positions)
const segments: Segment[] = []
for (let i = 0; i < text.length; i++) {
const hit = hits.has(i)
const last = segments[segments.length - 1]
if (last && last.hit === hit) last.text += text[i]
else segments.push({ text: text[i], hit })
}
return segments
}

/**
* Renders `text` with the characters that match `query` emphasized. Falls back
* to plain text when there is no query or no positional match against the
* display text (e.g. the row matched on a hidden id rather than its label).
*/
export const HighlightedText = memo(
function HighlightedText({ text, query }: { text: string; query?: string }) {
if (!query) return <>{text}</>
const { positions } = fuzzyMatch(text, query)
if (positions.length === 0) return <>{text}</>
return (
<>
{buildSegments(text, positions).map((segment, index) =>
segment.hit ? (
<span key={index} className='font-semibold text-[var(--text-body)]'>
{segment.text}
</span>
) : (
<span key={index}>{segment.text}</span>
)
)}
</>
)
},
(prev, next) => prev.text === next.text && prev.query === next.query
)

export const MemoizedCommandItem = memo(
function CommandItem({
Expand All @@ -15,7 +62,8 @@ export const MemoizedCommandItem = memo(
icon: Icon,
bgColor,
showColoredIcon,
children,
label,
query,
}: CommandItemProps) {
return (
<Command.Item value={value} onSelect={onSelect} className={COMMAND_ITEM_CLASSNAME}>
Expand All @@ -32,7 +80,9 @@ export const MemoizedCommandItem = memo(
)}
/>
</div>
<span className='truncate text-[var(--text-body)]'>{children}</span>
<span className='truncate text-[var(--text-body)]'>
<HighlightedText text={label} query={query} />
</span>
</Command.Item>
)
},
Expand All @@ -41,7 +91,46 @@ export const MemoizedCommandItem = memo(
prev.icon === next.icon &&
prev.bgColor === next.bgColor &&
prev.showColoredIcon === next.showColoredIcon &&
prev.children === next.children
prev.label === next.label &&
prev.query === next.query
)

export const MemoizedActionItem = memo(
function ActionItem({
value,
onSelect,
icon: Icon,
name,
shortcut,
query,
}: {
value: string
onSelect: () => void
icon: ComponentType<{ className?: string }>
name: string
shortcut?: string
query?: string
}) {
return (
<Command.Item value={value} onSelect={onSelect} className={COMMAND_ITEM_CLASSNAME}>
<Icon className='size-[16px] flex-shrink-0 text-[var(--text-icon)]' />
<span className='truncate text-[var(--text-body)]'>
<HighlightedText text={name} query={query} />
</span>
{shortcut && (
<span className='ml-auto flex-shrink-0 text-[var(--text-subtle)] text-small'>
{shortcut}
</span>
)}
</Command.Item>
)
},
(prev, next) =>
prev.value === next.value &&
prev.icon === next.icon &&
prev.name === next.name &&
prev.shortcut === next.shortcut &&
prev.query === next.query
)

export const MemoizedWorkflowItem = memo(
Expand All @@ -51,20 +140,24 @@ export const MemoizedWorkflowItem = memo(
name,
folderPath,
isCurrent,
query,
}: {
value: string
onSelect: () => void
name: string
folderPath?: string[]
isCurrent?: boolean
query?: string
}) {
return (
<Command.Item value={value} onSelect={onSelect} className={COMMAND_ITEM_CLASSNAME}>
<div className='relative flex size-[16px] flex-shrink-0 items-center justify-center'>
<Workflow className='size-[14px] text-[var(--text-icon)]' />
</div>
<span className='flex min-w-0 max-w-[75%] flex-shrink-0 text-[var(--text-body)]'>
<span className='truncate'>{name}</span>
<span className='truncate'>
<HighlightedText text={name} query={query} />
</span>
{isCurrent && <span className='flex-shrink-0 whitespace-pre'> (current)</span>}
</span>
{folderPath && folderPath.length > 0 && (
Expand All @@ -87,6 +180,7 @@ export const MemoizedWorkflowItem = memo(
prev.value === next.value &&
prev.name === next.name &&
prev.isCurrent === next.isCurrent &&
prev.query === next.query &&
(prev.folderPath === next.folderPath ||
(prev.folderPath?.length === next.folderPath?.length &&
(prev.folderPath ?? []).every((segment, i) => segment === next.folderPath?.[i])))
Expand All @@ -98,19 +192,23 @@ export const MemoizedFileItem = memo(
onSelect,
name,
folderPath,
query,
}: {
value: string
onSelect: () => void
name: string
folderPath?: string[]
query?: string
}) {
return (
<Command.Item value={value} onSelect={onSelect} className={COMMAND_ITEM_CLASSNAME}>
<div className='relative flex size-[16px] flex-shrink-0 items-center justify-center'>
<File className='size-[14px] text-[var(--text-icon)]' />
</div>
<span className='flex min-w-0 max-w-[75%] flex-shrink-0 font-base text-[var(--text-body)]'>
<span className='truncate'>{name}</span>
<span className='truncate'>
<HighlightedText text={name} query={query} />
</span>
</span>
{folderPath && folderPath.length > 0 && (
<span className='ml-auto flex min-w-0 pl-2 font-base text-[var(--text-subtle)] text-small'>
Expand All @@ -131,6 +229,7 @@ export const MemoizedFileItem = memo(
(prev, next) =>
prev.value === next.value &&
prev.name === next.name &&
prev.query === next.query &&
(prev.folderPath === next.folderPath ||
(prev.folderPath?.length === next.folderPath?.length &&
(prev.folderPath ?? []).every((segment, i) => segment === next.folderPath?.[i])))
Expand All @@ -141,18 +240,22 @@ export const MemoizedTaskItem = memo(
value,
onSelect,
name,
query,
}: {
value: string
onSelect: () => void
name: string
query?: string
}) {
return (
<Command.Item value={value} onSelect={onSelect} className={COMMAND_ITEM_CLASSNAME}>
<span className='truncate text-[var(--text-body)]'>{name}</span>
<span className='truncate text-[var(--text-body)]'>
<HighlightedText text={name} query={query} />
</span>
</Command.Item>
)
},
(prev, next) => prev.value === next.value && prev.name === next.name
(prev, next) => prev.value === next.value && prev.name === next.name && prev.query === next.query
)

export const MemoizedWorkspaceItem = memo(
Expand All @@ -161,23 +264,30 @@ export const MemoizedWorkspaceItem = memo(
onSelect,
name,
isCurrent,
query,
}: {
value: string
onSelect: () => void
name: string
isCurrent?: boolean
query?: string
}) {
return (
<Command.Item value={value} onSelect={onSelect} className={COMMAND_ITEM_CLASSNAME}>
<span className='flex min-w-0 text-[var(--text-body)]'>
<span className='truncate'>{name}</span>
<span className='truncate'>
<HighlightedText text={name} query={query} />
</span>
{isCurrent && <span className='flex-shrink-0 whitespace-pre'> (current)</span>}
</span>
</Command.Item>
)
},
(prev, next) =>
prev.value === next.value && prev.name === next.name && prev.isCurrent === next.isCurrent
prev.value === next.value &&
prev.name === next.name &&
prev.isCurrent === next.isCurrent &&
prev.query === next.query
)

export const MemoizedPageItem = memo(
Expand All @@ -187,17 +297,21 @@ export const MemoizedPageItem = memo(
icon: Icon,
name,
shortcut,
query,
}: {
value: string
onSelect: () => void
icon: ComponentType<{ className?: string }>
name: string
shortcut?: string
query?: string
}) {
return (
<Command.Item value={value} onSelect={onSelect} className={COMMAND_ITEM_CLASSNAME}>
<Icon className='size-[16px] flex-shrink-0 text-[var(--text-icon)]' />
<span className='truncate text-[var(--text-body)]'>{name}</span>
<span className='truncate text-[var(--text-body)]'>
<HighlightedText text={name} query={query} />
</span>
{shortcut && (
<span className='ml-auto flex-shrink-0 text-[var(--text-subtle)] text-small'>
{shortcut}
Expand All @@ -210,7 +324,8 @@ export const MemoizedPageItem = memo(
prev.value === next.value &&
prev.icon === next.icon &&
prev.name === next.name &&
prev.shortcut === next.shortcut
prev.shortcut === next.shortcut &&
prev.query === next.query
)

export const MemoizedIconItem = memo(
Expand All @@ -219,18 +334,26 @@ export const MemoizedIconItem = memo(
onSelect,
name,
icon: Icon,
query,
}: {
value: string
onSelect: () => void
name: string
icon: ComponentType<{ className?: string }>
query?: string
}) {
return (
<Command.Item value={value} onSelect={onSelect} className={COMMAND_ITEM_CLASSNAME}>
<Icon className='size-[16px] flex-shrink-0 text-[var(--text-icon)]' />
<span className='truncate text-[var(--text-body)]'>{name}</span>
<span className='truncate text-[var(--text-body)]'>
<HighlightedText text={name} query={query} />
</span>
</Command.Item>
)
},
(prev, next) => prev.value === next.value && prev.name === next.name && prev.icon === next.icon
(prev, next) =>
prev.value === next.value &&
prev.name === next.name &&
prev.icon === next.icon &&
prev.query === next.query
)
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
export {
HighlightedText,
MemoizedActionItem,
MemoizedCommandItem,
MemoizedFileItem,
MemoizedIconItem,
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
export {
ActionsGroup,
BlocksGroup,
ChatsGroup,
ConnectedAccountsGroup,
Expand Down
Loading
Loading