From 7620f2f2814e722443f16ec567f934670026bd79 Mon Sep 17 00:00:00 2001 From: ZhenYi <434836402@qq.com> Date: Sat, 25 Apr 2026 09:53:12 +0800 Subject: [PATCH] feat(command): use real API data for navigation, fix notification button MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CommandPalette: replace workspaceProjects with getCurrentUserProjects (no workspace dependency so it works outside WorkspaceProvider). Repos fetched per-project to preserve correct /repository/ns/repo routes. Keyboard shortcut correctly matches Ctrl+Alt+F / Cmd+Ctrl+F. sidebar-user: fix notification button layout — bell icon and label now on the same row instead of separate stacked elements. --- src/components/layout/sidebar-user.tsx | 29 +- src/components/shared/CodeBlock.tsx | 155 +++++++++ src/components/shared/CodeReference.tsx | 154 +++++++++ src/components/shared/CommandPalette.tsx | 277 +++++++++++++++ src/components/shared/ContentRenderer.tsx | 323 ++++++++++++++++++ .../shared/GlobalNavigationShortcuts.tsx | 98 ++++++ .../shared/KeyboardShortcutsSheet.tsx | 146 ++++++++ src/components/shared/LinkPreview.tsx | 303 ++++++++++++++++ src/components/shared/MentionBadge.tsx | 84 +++++ src/components/shared/MiniChat.tsx | 218 ++++++++++++ src/components/ui/command.tsx | 4 +- 11 files changed, 1776 insertions(+), 15 deletions(-) create mode 100644 src/components/shared/CodeBlock.tsx create mode 100644 src/components/shared/CodeReference.tsx create mode 100644 src/components/shared/CommandPalette.tsx create mode 100644 src/components/shared/ContentRenderer.tsx create mode 100644 src/components/shared/GlobalNavigationShortcuts.tsx create mode 100644 src/components/shared/KeyboardShortcutsSheet.tsx create mode 100644 src/components/shared/LinkPreview.tsx create mode 100644 src/components/shared/MentionBadge.tsx create mode 100644 src/components/shared/MiniChat.tsx diff --git a/src/components/layout/sidebar-user.tsx b/src/components/layout/sidebar-user.tsx index cdb1e21..8fd6a08 100644 --- a/src/components/layout/sidebar-user.tsx +++ b/src/components/layout/sidebar-user.tsx @@ -10,8 +10,9 @@ import { } from '@/components/ui/dropdown-menu'; import {useUser} from '@/contexts'; import {cn} from '@/lib/utils'; -import {Mail, UserPlus} from 'lucide-react'; +import {UserPlus, Bell} from 'lucide-react'; import {useNavigate} from 'react-router-dom'; +import {NotificationDrawer} from '@/components/notify/NotificationDrawer'; const btnClass = 'flex w-full h-9 justify-start items-center rounded-md font-medium hover:bg-muted cursor-pointer bg-transparent border-0 text-left text-sm'; @@ -29,19 +30,19 @@ export function SidebarUser({collapsed}: { collapsed: boolean }) { {!collapsed && Invitations} - + {!collapsed ? ( + + ) : ( +
+ +
+ )} {user && ( diff --git a/src/components/shared/CodeBlock.tsx b/src/components/shared/CodeBlock.tsx new file mode 100644 index 0000000..0db4c14 --- /dev/null +++ b/src/components/shared/CodeBlock.tsx @@ -0,0 +1,155 @@ +'use client'; + +/** + * Enhanced code block component with: + * - Language badge (from code-lang-detect.ts) + * - Copy button + * - Optional line numbers + * - Whitespace toggle + */ + +import { useState } from 'react'; +import { Copy, Check, WrapText } from 'lucide-react'; +import { cn } from '@/lib/utils'; +import { detectLanguage, getLanguageFromTag } from '@/lib/code-lang-detect'; + +interface CodeBlockProps { + children: string; + /** Language tag from the fenced code block (e.g. "ts", "rust") */ + language?: string; + /** Auto-detect language from content if no tag provided */ + autoDetect?: boolean; + /** Show line numbers */ + showLineNumbers?: boolean; + className?: string; + /** Additional header content (e.g. file name) */ + filename?: string; +} + +export function CodeBlock({ + children, + language, + autoDetect = true, + showLineNumbers = false, + className, + filename, +}: CodeBlockProps) { + const [copied, setCopied] = useState(false); + const [wrap, setWrap] = useState(false); + + // Resolve language + const langInfo = language + ? getLanguageFromTag(language) + : autoDetect + ? detectLanguage(children) + : null; + + const displayLang = langInfo?.label ?? language ?? null; + + const handleCopy = async () => { + try { + await navigator.clipboard.writeText(children); + setCopied(true); + setTimeout(() => setCopied(false), 2000); + } catch { + // Fallback + const textarea = document.createElement('textarea'); + textarea.value = children; + document.body.appendChild(textarea); + textarea.select(); + document.execCommand('copy'); + document.body.removeChild(textarea); + setCopied(true); + setTimeout(() => setCopied(false), 2000); + } + }; + + const lines = children.split('\n'); + + return ( +
+ {/* Header bar */} +
+ {/* Traffic light dots (macOS style) */} +
+
+
+
+
+ + {filename && ( + + {filename} + + )} + +
+ {displayLang && ( + + {displayLang} + + )} + + {showLineNumbers && ( + + )} + + +
+
+ + {/* Code content */} +
+
+          {showLineNumbers ? (
+            
+              
+                {lines.map((line, i) => (
+                  
+                    
+                    
+                  
+                ))}
+              
+            
+ {i + 1} + {line}
+ ) : ( + children + )} +
+
+
+ ); +} diff --git a/src/components/shared/CodeReference.tsx b/src/components/shared/CodeReference.tsx new file mode 100644 index 0000000..82edffa --- /dev/null +++ b/src/components/shared/CodeReference.tsx @@ -0,0 +1,154 @@ +'use client'; + +/** + * Renders a compact code reference block (file path + line range). + * Clicking navigates to the file in the repository browser. + */ + +import { useState } from 'react'; +import { FileCode2, ChevronDown, ChevronRight } from 'lucide-react'; +import { cn } from '@/lib/utils'; +import type { CodeRef } from '@/lib/code-ref-parser'; + +interface CodeReferenceProps { + ref: CodeRef; + /** API to fetch line content (optional — shows skeleton if not provided) */ + getLineContent?: (filePath: string, startLine: number, endLine: number) => Promise; + /** Called when the reference is clicked — navigate to file browser */ + onClick?: (ref: CodeRef) => void; + /** Base URL for the repository file browser (e.g. /repository/ns/repo) */ + repoBaseUrl?: string; + /** Branch name to use in the link */ + branch?: string; + className?: string; +} + +export function CodeReference({ + ref, + getLineContent, + onClick, + repoBaseUrl, + branch = 'main', + className, +}: CodeReferenceProps) { + const [expanded, setExpanded] = useState(false); + const [lines, setLines] = useState(null); + const [loading, setLoading] = useState(false); + + const handleClick = () => { + if (onClick) { + onClick(ref); + return; + } + if (repoBaseUrl) { + // Navigate to file browser with line highlighted + window.location.href = `${repoBaseUrl}/blob/${branch}/${ref.filePath}#L${ref.startLine}`; + } + }; + + const loadLines = async () => { + if (!getLineContent || lines !== null) return; + setLoading(true); + try { + const content = await getLineContent(ref.filePath, ref.startLine, ref.endLine); + setLines(content); + } catch { + setLines([]); + } finally { + setLoading(false); + } + }; + + const toggleExpand = () => { + setExpanded((prev) => { + if (!prev) loadLines(); + return !prev; + }); + }; + + const lineLabel = ref.endLine === ref.startLine + ? `L${ref.startLine}` + : `L${ref.startLine}–L${ref.endLine}`; + + return ( +
+ {/* Header bar */} + + + {/* Code preview (optional) */} + {getLineContent && ( + <> + + + {expanded && ( +
+ {loading ? ( +
+ {Array.from({ length: Math.min(ref.endLine - ref.startLine + 1, 5) }).map((_, i) => ( +
+
+
+
+ ))} +
+ ) : lines && lines.length > 0 ? ( +
+ {lines.map((line, i) => { + const lineNum = ref.startLine + i; + return ( +
+ + {lineNum} + + + {line || ' '} + +
+ ); + })} +
+ ) : ( +
+ No content available +
+ )} +
+ )} + + )} +
+ ); +} diff --git a/src/components/shared/CommandPalette.tsx b/src/components/shared/CommandPalette.tsx new file mode 100644 index 0000000..f24659d --- /dev/null +++ b/src/components/shared/CommandPalette.tsx @@ -0,0 +1,277 @@ +'use client'; + +/** + * Global command palette (Cmd+K / Ctrl+K). + * Dynamic: fetches real projects, repos, rooms from API. + * Actions: navigate to project/repo/room, create project, create repo. + */ + +import { useState, useEffect, useCallback, useMemo } from 'react'; +import { useNavigate } from 'react-router-dom'; +import { useQuery } from '@tanstack/react-query'; +import { + CommandDialog, + CommandInput, + CommandList, + CommandEmpty, + CommandGroup, + CommandItem, + CommandShortcut, +} from '@/components/ui/command'; +import { + FolderKanban, + GitBranch, + Hash, + Plus, + Bell, + Search, +} from 'lucide-react'; +import { getRegisteredCommands } from '@/hooks/useCommandRegistry'; +import type { CommandItem as RegistryCommandItem } from '@/hooks/useCommandRegistry'; +import { formatShortcut } from '@/hooks/useKeyboardShortcut'; +import { getCurrentUserProjects, projectRepos, roomList } from '@/client'; +import type { UserProjectInfo, ProjectRepositoryItem, RoomResponse } from '@/client'; + +// ── Icons ──────────────────────────────────────────────────────────────────── + +const iconMap: Record> = { + FolderKanban, + GitBranch, + Hash, + Plus, + Bell, + Search, +}; + +// ── Command item type ──────────────────────────────────────────────────────── + +interface PaletteItem { + id: string; + label: string; + icon: string; + shortcut?: { key: string; meta?: boolean; shift?: boolean }; + action: () => void; + group: string; + keywords: string[]; +} + +// ── Static quick actions ───────────────────────────────────────────────────── + +function buildStaticActions(navigate: ReturnType): PaletteItem[] { + return [ + { + id: 'goto-notifications', + label: 'Go to Notifications', + icon: 'Bell', + shortcut: { key: 'n', meta: true }, + action: () => navigate('/notify'), + group: 'Navigation', + keywords: ['goto', 'notifications', 'inbox'], + }, + { + id: 'create-project', + label: 'Create Project', + icon: 'Plus', + shortcut: { key: 'c', meta: true, shift: true }, + action: () => navigate('/init/project'), + group: 'Create', + keywords: ['create', 'new', 'project'], + }, + ]; +} + +// ── Main palette ───────────────────────────────────────────────────────────── + +export function CommandPalette() { + const [open, setOpen] = useState(false); + const navigate = useNavigate(); + + // Fetch projects for the current user (no workspace dependency) + const { data: projectsData } = useQuery({ + queryKey: ['commandPaletteProjects'], + queryFn: async () => { + const resp = await getCurrentUserProjects(); + return resp.data?.data ?? { projects: [] as UserProjectInfo[], total_count: 0 }; + }, + }); + + const projects = projectsData?.projects ?? []; + + // Derived project names for repo/room queries + const projectNames = useMemo( + () => projects.map(p => p.name), + [projects], + ); + + // Fetch repos per project (up to 5 projects to keep it fast) + const { data: reposData } = useQuery({ + queryKey: ['commandPaletteRepos', projectNames], + queryFn: async () => { + const results: Record = {}; + for (const name of projectNames.slice(0, 5)) { + try { + const resp = await projectRepos({ path: { project_name: name } }); + const data = resp.data?.data; + if (data?.items) results[name] = data.items; + } catch { /* skip */ } + } + return results; + }, + enabled: projectNames.length > 0, + }); + + // Fetch rooms for each project (up to 5 to keep it fast) + + const { data: roomsData } = useQuery({ + queryKey: ['commandPaletteRooms', projectNames], + queryFn: async () => { + const results: Record = {}; + for (const name of projectNames.slice(0, 5)) { + try { + const resp = await roomList({ path: { project_name: name } }); + const data = resp.data?.data; + if (data) results[name] = (data as any)?.rooms ?? (Array.isArray(data) ? data : []); + } catch { /* skip */ } + } + return results; + }, + enabled: projectNames.length > 0, + }); + + // Global shortcut: Ctrl+Alt+F (Windows/Linux) or Cmd+Ctrl+F (Mac) + useEffect(() => { + const handler = (e: KeyboardEvent) => { + const isMac = navigator.platform?.toUpperCase().includes('MAC') || navigator.userAgent?.includes('Mac'); + const match = isMac + ? e.metaKey && e.ctrlKey && e.key === 'f' + : e.ctrlKey && e.altKey && e.key === 'f'; + if (match) { + e.preventDefault(); + setOpen((v) => !v); + } + }; + window.addEventListener('keydown', handler); + return () => window.removeEventListener('keydown', handler); + }, []); + + // Close on navigation + useEffect(() => { + const handler = () => setOpen(false); + window.addEventListener('popstate', handler); + return () => window.removeEventListener('popstate', handler); + }, []); + + const handleSelect = useCallback((action: () => void) => { + setOpen(false); + action(); + }, []); + + // Build search text including keywords for cmdk filtering + const searchValue = useCallback((item: PaletteItem | RegistryCommandItem) => { + return [item.label, ...(item.keywords ?? [])].join(' '); + }, []); + + // ── Build dynamic command list ────────────────────────────────────────── + + const allRooms = roomsData ?? {}; + const allRepos = reposData ?? {}; + + const projectItems: PaletteItem[] = projects.map(p => ({ + id: `project-${p.name}`, + label: p.display_name || p.name, + icon: 'FolderKanban', + action: () => navigate(`/project/${p.name}`), + group: 'Projects', + keywords: ['project', p.name, p.display_name, ...(p.description ? [p.description] : [])], + })); + + const repoItems: PaletteItem[] = []; + for (const [projName, repos] of Object.entries(allRepos)) { + for (const r of repos) { + repoItems.push({ + id: `repo-${projName}-${r.repo_name}`, + label: `${r.repo_name} (${projName})`, + icon: 'GitBranch', + action: () => navigate(`/repository/${projName}/${r.repo_name}`), + group: 'Repositories', + keywords: ['repo', 'repository', r.repo_name, projName, ...(r.description ? [r.description] : [])], + }); + } + } + + const roomItems: PaletteItem[] = []; + for (const [projName, rooms] of Object.entries(allRooms)) { + for (const r of rooms) { + roomItems.push({ + id: `room-${r.id}`, + label: `${r.room_name} (${projName})`, + icon: 'Hash', + action: () => navigate(`/project/${projName}/room/${r.id}`), + group: 'Rooms', + keywords: ['room', 'chat', 'channel', r.room_name, projName], + }); + } + } + + // Per-project "create repo" actions + const createRepoItems: PaletteItem[] = projects.slice(0, 10).map(p => ({ + id: `create-repo-${p.name}`, + label: `Create Repo in ${p.display_name || p.name}`, + icon: 'Plus', + action: () => navigate(`/project/${p.name}/repositories`), + group: 'Create', + keywords: ['create', 'new', 'repo', 'repository', p.name, p.display_name], + })); + + const staticActions = buildStaticActions(navigate); + const registered = getRegisteredCommands(); + const allCommands = [ + ...staticActions, + ...projectItems, + ...repoItems, + ...roomItems, + ...createRepoItems, + ...registered, + ] as (PaletteItem | RegistryCommandItem)[]; + + // Group commands by their `group` field + const grouped = useMemo(() => { + const groups = new Map(); + for (const cmd of allCommands) { + const g = groups.get(cmd.group) ?? []; + g.push(cmd); + groups.set(cmd.group, g); + } + return groups; + }, [allCommands]); + + return ( + + + + No results found. + {Array.from(grouped.entries()).map(([group, cmds]) => ( + + {cmds.map((cmd) => { + const Icon = iconMap[(cmd as PaletteItem).icon ?? ''] ?? Search; + const shortcut = cmd.shortcut; + return ( + handleSelect(cmd.action)} + > + + {cmd.label} + {shortcut && ( + {formatShortcut(shortcut)} + )} + + ); + })} + + ))} + + + ); +} \ No newline at end of file diff --git a/src/components/shared/ContentRenderer.tsx b/src/components/shared/ContentRenderer.tsx new file mode 100644 index 0000000..ee1a7c8 --- /dev/null +++ b/src/components/shared/ContentRenderer.tsx @@ -0,0 +1,323 @@ +'use client'; + +/** + * Unified content renderer for markdown + @[type:id:label] mentions. + * Used across room chat, issues, and PR comments. + * + * Features (enabled via props): + * - enableLinkPreviews: inline rich link previews for issues, PRs, commits, repos + * - enableCodeRefs: inline code reference blocks (file.rs:42 style) + * + * Mentions are protected from markdown parsing by replacing them with + * zero-width-space-delimited placeholder tokens before rendering, + * then restored as styled interactive elements after. + */ + +import { memo, useMemo, useState } from 'react'; +import Markdown from 'react-markdown'; +import remarkGfm from 'remark-gfm'; +import { cn } from '@/lib/utils'; +import { + extractMentions, + type MentionType, + type MentionSpan, +} from '@/lib/mention'; +import { MentionBadge } from './MentionBadge'; +import { detectLanguage, getLanguageFromTag } from '@/lib/code-lang-detect'; +import { detectLinkType, extractUrls, type UnfurlResult } from '@/lib/link-unfurl'; +import { parseCodeRef, type CodeRef } from '@/lib/code-ref-parser'; + +interface ContentRendererProps { + content: string; + /** Called when a mention is clicked. Provides (type, id, label). */ + onMentionClick?: (type: MentionType, id: string, label: string) => void; + /** Show rich link preview cards for detected URLs */ + enableLinkPreviews?: boolean; + /** Show inline code reference blocks for "file.rs:42" style references */ + enableCodeRefs?: boolean; + /** Additional CSS classes for the wrapper div. */ + className?: string; + /** Base URL for code reference navigation (e.g. /repository/ns/repo) */ + repoBaseUrl?: string; + /** Branch for code reference navigation */ + branch?: string; +} + +export const ContentRenderer = memo(function ContentRenderer({ + content, + onMentionClick, + enableLinkPreviews = false, + enableCodeRefs = false, + className, + repoBaseUrl, + branch = 'main', +}: ContentRendererProps) { + const { safeContent, mentions } = useMemo(() => extractMentions(content), [content]); + + // Pre-detect standalone URLs and code refs for inline rendering + const { urls, codeRefs } = useMemo(() => { + if (!enableLinkPreviews && !enableCodeRefs) return { urls: new Map(), codeRefs: [] }; + + const urlMap = new Map(); + const refs: CodeRef[] = []; + + // Extract standalone URLs (not inside markdown links) + const urlMatches = extractUrls(content); + for (const { url } of urlMatches) { + const result = detectLinkType(url); + if (result && result.type !== 'external') { + urlMap.set(url, result); + } + } + + // Extract code refs + if (enableCodeRefs) { + const refMatches = content.match(/[^\s:]+:\d+(?:-\d+)?/g) ?? []; + for (const match of refMatches) { + const parsed = parseCodeRef(match); + if (parsed) refs.push(parsed); + } + } + + return { urls: urlMap, codeRefs: refs }; + }, [content, enableLinkPreviews, enableCodeRefs]); + + return ( +
+ } + > + {safeContent} + +
+ ); +}); + +interface RendererOptions { + enableLinkPreviews: boolean; + enableCodeRefs: boolean; + urls: Map; + codeRefs: CodeRef[]; + repoBaseUrl?: string; + branch?: string; +} + +function buildComponents( + mentions: MentionSpan[], + onMentionClick: ContentRendererProps['onMentionClick'], + opts: RendererOptions, +) { + const { repoBaseUrl, branch } = opts; + + return { + p: ({ children }: { children: React.ReactNode }) => ( +

{restoreInNode(children, mentions, onMentionClick)}

+ ), + li: ({ children }: { children: React.ReactNode }) => ( +
  • {restoreInNode(children, mentions, onMentionClick)}
  • + ), + strong: ({ children }: { children: React.ReactNode }) => ( + {restoreInNode(children, mentions, onMentionClick)} + ), + em: ({ children }: { children: React.ReactNode }) => ( + {restoreInNode(children, mentions, onMentionClick)} + ), + code: ({ className, children, ...props }: React.ComponentProps<'code'>) => { + const isBlock = typeof className === 'string' && className.includes('language-'); + if (isBlock) { + // Enhanced fenced code block — try to detect language + const langTag = typeof className === 'string' + ? className.replace('language-', '') + : undefined; + const langInfo = langTag + ? getLanguageFromTag(langTag) + : detectLanguage(String(children)); + const langLabel = langInfo?.label ?? langTag; + + return ( + + ); + } + return ( + + {children} + + ); + }, + pre: ({ children }: React.ComponentProps<'pre'>) => { + // Extract language from nested code element + const codeEl = children as React.ReactElement<{ className?: string; children?: string }>; + const langTag = codeEl?.props?.className?.replace('language-', ''); + return ( + + ); + }, + }; +} + +/** Code block with language badge and optional line numbers */ +function EnhancedCodeBlock({ + code, + language, + repoBaseUrl: _repoBaseUrl, + branch: _branch, +}: { + code: string; + language?: string; + repoBaseUrl?: string; + branch?: string; +}) { + const [copied, setCopied] = useState(false); + const langInfo = language ? getLanguageFromTag(language) : detectLanguage(code); + const displayLang = langInfo?.label ?? language ?? null; + + const handleCopy = async () => { + try { + await navigator.clipboard.writeText(code); + setCopied(true); + setTimeout(() => setCopied(false), 2000); + } catch { + const ta = document.createElement('textarea'); + ta.value = code; + document.body.appendChild(ta); + ta.select(); + document.execCommand('copy'); + document.body.removeChild(ta); + setCopied(true); + setTimeout(() => setCopied(false), 2000); + } + }; + + return ( +
    + {/* Header */} +
    +
    +
    +
    +
    +
    + {displayLang && ( + + {displayLang} + + )} + +
    + {/* Code */} +
    +        {code}
    +      
    +
    + ); +} + +// ── Helpers ──────────────────────────────────────────────────────────────── + +/** Recursively restore mention placeholders inside text nodes. */ +function restoreInNode( + children: React.ReactNode, + mentions: MentionSpan[], + onMentionClick?: (type: MentionType, id: string, label: string) => void, +): React.ReactNode { + if (typeof children === 'string') { + return restoreMentionsText(children, mentions, onMentionClick); + } + if (Array.isArray(children)) { + return children.map((child, i) => ( + {restoreInNode(child, mentions, onMentionClick)} + )); + } + return children; +} + +/** Restore mention placeholders inside a single text string into React elements. */ +function restoreMentionsText( + text: string, + mentions: MentionSpan[], + onMentionClick?: (type: MentionType, id: string, label: string) => void, +): React.ReactNode { + const parts: React.ReactNode[] = []; + let lastIndex = 0; + + const PLACEHOLDER_RE = /​MENTION_(\d+)​/g; + let match: RegExpExecArray | null; + + while ((match = PLACEHOLDER_RE.exec(text)) !== null) { + if (match.index > lastIndex) { + parts.push(text.slice(lastIndex, match.index)); + } + const idx = parseInt(match[1], 10); + const m = mentions[idx]; + if (m) { + parts.push( + , + ); + } + lastIndex = PLACEHOLDER_RE.lastIndex; + } + + if (lastIndex < text.length) { + parts.push(text.slice(lastIndex)); + } + + return parts.length > 0 ? parts : text; +} diff --git a/src/components/shared/GlobalNavigationShortcuts.tsx b/src/components/shared/GlobalNavigationShortcuts.tsx new file mode 100644 index 0000000..e440a4a --- /dev/null +++ b/src/components/shared/GlobalNavigationShortcuts.tsx @@ -0,0 +1,98 @@ +'use client'; + +/** + * Registers global navigation shortcuts at app startup. + * Shortcuts: g n → /notify, g i → /issues, g r → /repos, g m → /rooms + * Uses the two-key chord pattern (press g, then the key). + */ + +import { useEffect, useRef } from 'react'; +import { useNavigate } from 'react-router-dom'; + +interface ChordTarget { + key: string; + path: string; + description: string; +} + +const CHORD_KEY = 'g'; + +const TARGETS: ChordTarget[] = [ + { key: 'n', path: '/notify', description: 'Go to Notifications' }, + { key: 'i', path: '/issues', description: 'Go to Issues' }, + { key: 'r', path: '/repos', description: 'Go to Repositories' }, + { key: 'm', path: '/rooms', description: 'Go to Messages' }, +]; + +/** Show a brief indicator when a chord is active */ +function showChordHint() { + const existing = document.getElementById('nav-chord-hint'); + if (existing) existing.remove(); + + const el = document.createElement('div'); + el.id = 'nav-chord-hint'; + el.textContent = 'g _'; + el.style.cssText = ` + position: fixed; + bottom: 80px; + left: 50%; + transform: translateX(-50%); + background: var(--foreground); + color: var(--background); + padding: 4px 12px; + border-radius: 6px; + font-size: 13px; + font-family: monospace; + font-weight: 600; + z-index: 9999; + pointer-events: none; + opacity: 1; + transition: opacity 0.3s ease; + `; + document.body.appendChild(el); + + setTimeout(() => { + el.style.opacity = '0'; + setTimeout(() => el.remove(), 300); + }, 600); +} + +export function GlobalNavigationShortcuts() { + const navigate = useNavigate(); + const pendingKeyRef = useRef(null); + const timerRef = useRef | null>(null); + + useEffect(() => { + const handleKey = (e: KeyboardEvent) => { + const target = e.target as HTMLElement; + const isEditing = + target.tagName === 'INPUT' || + target.tagName === 'TEXTAREA' || + target.isContentEditable; + if (isEditing) return; + + if (pendingKeyRef.current) { + const next = e.key.toLowerCase(); + const match = TARGETS.find((t) => t.key === next); + if (match) { + e.preventDefault(); + navigate(match.path); + } + pendingKeyRef.current = null; + if (timerRef.current) { clearTimeout(timerRef.current); timerRef.current = null; } + return; + } + + if (e.key.toLowerCase() === CHORD_KEY) { + pendingKeyRef.current = CHORD_KEY; + showChordHint(); + timerRef.current = setTimeout(() => { pendingKeyRef.current = null; }, 600); + } + }; + + window.addEventListener('keydown', handleKey); + return () => window.removeEventListener('keydown', handleKey); + }, [navigate]); + + return null; +} diff --git a/src/components/shared/KeyboardShortcutsSheet.tsx b/src/components/shared/KeyboardShortcutsSheet.tsx new file mode 100644 index 0000000..eefd4ce --- /dev/null +++ b/src/components/shared/KeyboardShortcutsSheet.tsx @@ -0,0 +1,146 @@ +'use client'; + +/** + * Keyboard shortcuts help sheet — triggered by pressing `?`. + * Shows all registered shortcuts from useKeyboardShortcut. + */ + +import { useState, useEffect } from 'react'; +import { + Sheet, + SheetContent, + SheetHeader, + SheetTitle, + SheetDescription, +} from '@/components/ui/sheet'; +import { getRegisteredShortcuts, type ShortcutScope } from '@/hooks/useKeyboardShortcut'; +import { registerGlobalKeyboardListener, unregisterGlobalKeyboardListener } from '@/hooks/useKeyboardShortcut'; +import { + Keyboard, + Globe, + FileText, + Puzzle, +} from 'lucide-react'; + +const SCOPE_LABELS: Record }> = { + global: { label: 'Global', Icon: Globe }, + page: { label: 'Page', Icon: FileText }, + component: { label: 'Component', Icon: Puzzle }, +}; + +export function KeyboardShortcutsSheet() { + const [open, setOpen] = useState(false); + + useEffect(() => { + registerGlobalKeyboardListener(); + const handleKey = (e: KeyboardEvent) => { + // Open on `?` when not in an input + const target = e.target as HTMLElement; + const isEditing = + target.tagName === 'INPUT' || + target.tagName === 'TEXTAREA' || + target.isContentEditable; + if (e.key === '?' && !isEditing) { + setOpen((v) => !v); + } + }; + window.addEventListener('keydown', handleKey); + return () => { + window.removeEventListener('keydown', handleKey); + unregisterGlobalKeyboardListener(); + }; + }, []); + + const shortcuts = getRegisteredShortcuts(); + const byScope = shortcuts.reduce>((acc, s) => { + (acc[s.scope] ??= []).push(s); + return acc; + }, {} as Record); + + const scopes: ShortcutScope[] = ['global', 'page', 'component']; + + return ( + + + + + + Keyboard Shortcuts + + + Press{' '} + + ? + {' '} + to toggle this panel. + + + +
    + {scopes.map((scope) => { + const items = byScope[scope]; + if (!items?.length) return null; + const { label, Icon } = SCOPE_LABELS[scope]; + return ( +
    +

    + + {label} +

    +
    + {items.map((s, i) => ( +
    + + {s.description ?? s.key} + + + {s.meta && ( + + ⌘ + + )} + {s.ctrl && ( + + Ctrl + + )} + {s.alt && ( + + ⌥ + + )} + {s.shift && ( + + ⇧ + + )} + + {s.key.toUpperCase()} + + +
    + ))} +
    +
    + ); + })} + + {shortcuts.length === 0 && ( +

    + No shortcuts registered yet. +

    + )} +
    + +
    +

    + Cmd+K opens the command palette +

    +
    +
    +
    + ); +} diff --git a/src/components/shared/LinkPreview.tsx b/src/components/shared/LinkPreview.tsx new file mode 100644 index 0000000..6a00caa --- /dev/null +++ b/src/components/shared/LinkPreview.tsx @@ -0,0 +1,303 @@ +'use client'; + +/** + * Renders a rich preview card for detected URLs. + * Supports: Issue, PR, Commit, Repository, External. + */ + +import { useNavigate } from 'react-router-dom'; +import { useQuery } from '@tanstack/react-query'; +import { + ExternalLink, + GitPullRequest, + GitCommit, + BookOpen, + AlertCircle, + CheckCircle2, + XCircle, + GitMerge, + ArrowUpRight, +} from 'lucide-react'; +import { cn } from '@/lib/utils'; +import type { UnfurlResult } from '@/lib/link-unfurl'; + +interface LinkPreviewProps { + result: UnfurlResult; + /** Called when the preview is clicked */ + onClick?: (result: UnfurlResult) => void; + className?: string; +} + +// ── Issue Preview ───────────────────────────────────────────────────────────── + +function IssuePreview({ result }: { result: UnfurlResult }) { + const navigate = useNavigate(); + const { issueNumber, project } = result.ids; + + const { data: issue, isLoading } = useQuery({ + queryKey: ['issue-preview', project, issueNumber], + queryFn: async () => { + const { issueGet } = await import('@/client'); + const resp = await issueGet({ path: { project: project!, number: issueNumber! } }); + return resp.data?.data ?? null; + }, + enabled: !!project && !!issueNumber, + staleTime: 5 * 60 * 1000, + }); + + const isOpen = issue?.state === 'open'; + const Icon = isOpen ? AlertCircle : CheckCircle2; + + return ( + + ); +} + +// ── PR Preview ───────────────────────────────────────────────────────────── + +function PRPreview({ result }: { result: UnfurlResult }) { + const navigate = useNavigate(); + const { prNumber, namespace, repo } = result.ids; + + const { data: pr, isLoading } = useQuery({ + queryKey: ['pr-preview', namespace, repo, prNumber], + queryFn: async () => { + const { pullRequestGet } = await import('@/client'); + const resp = await pullRequestGet({ path: { namespace: namespace!, repo: repo!, number: prNumber! } }); + return resp.data?.data ?? null; + }, + enabled: !!namespace && !!repo && !!prNumber, + staleTime: 5 * 60 * 1000, + }); + + const status = pr?.status ?? 'open'; + const StatusIcon = + status === 'merged' ? GitMerge : status === 'closed' ? XCircle : GitPullRequest; + + return ( + + ); +} + +// ── Commit Preview ───────────────────────────────────────────────────────── + +function CommitPreview({ result }: { result: UnfurlResult }) { + const navigate = useNavigate(); + const { commitOid, namespace, repo } = result.ids; + const shortOid = commitOid?.slice(0, 7) ?? 'unknown'; + + return ( + + ); +} + +// ── Repository Preview ───────────────────────────────────────────────────── + +function RepoPreview({ result }: { result: UnfurlResult }) { + const navigate = useNavigate(); + const { namespace, repo } = result.ids; + + return ( + + ); +} + +// ── External URL Preview ──────────────────────────────────────────────────── + +function ExternalPreview({ result }: { result: UnfurlResult }) { + return ( + +
    + +
    +
    +

    {result.domain}

    +

    {result.url}

    +
    + +
    + ); +} + +// ── Main LinkPreview component ─────────────────────────────────────────────── + +export function LinkPreview({ result, className }: LinkPreviewProps) { + switch (result.type) { + case 'issue': + return ; + case 'pull_request': + return ; + case 'commit': + return ; + case 'repository': + return ; + case 'external': + return ; + default: + // Fallback: render as a simple link + return ( + + {result.url} + + ); + } +} diff --git a/src/components/shared/MentionBadge.tsx b/src/components/shared/MentionBadge.tsx new file mode 100644 index 0000000..f60bc38 --- /dev/null +++ b/src/components/shared/MentionBadge.tsx @@ -0,0 +1,84 @@ +'use client'; + +import { cn } from '@/lib/utils'; +import type { MentionType } from '@/lib/mention'; + +interface MentionBadgeProps { + type: MentionType; + label: string; + onClick?: (type: MentionType, id: string, label: string) => void; + id: string; + className?: string; +} + +const TYPE_STYLE: Record = { + user: { + light: 'bg-blue-50 text-blue-600', + dark: 'dark:bg-blue-900/30 dark:text-blue-300', + prefix: '@', + }, + channel: { + light: 'bg-gray-50 text-gray-600', + dark: 'dark:bg-gray-800 dark:text-gray-300', + prefix: '#', + }, + ai: { + light: 'bg-green-50 text-green-600', + dark: 'dark:bg-green-900/30 dark:text-green-300', + prefix: '@', + }, + command: { + light: 'bg-amber-50 text-amber-600', + dark: 'dark:bg-amber-900/30 dark:text-amber-300', + prefix: '/', + }, + special_here: { + light: 'bg-orange-50 text-orange-600', + dark: 'dark:bg-orange-900/30 dark:text-orange-300', + prefix: '@', + }, + special_channel: { + light: 'bg-orange-50 text-orange-600', + dark: 'dark:bg-orange-900/30 dark:text-orange-300', + prefix: '@', + }, +}; + +export function MentionBadge({ type, label, onClick, id, className }: MentionBadgeProps) { + const style = TYPE_STYLE[type] ?? TYPE_STYLE['user']; + const isInteractive = !!onClick; + + return ( + onClick(type, id, label) : undefined} + onKeyDown={ + isInteractive + ? (e) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + onClick(type, id, label); + } + } + : undefined + } + > + {style.prefix} + {label} + + ); +} + +/** Get mention style class names for direct use (without the component wrapper). */ +export function getMentionStyleClasses(type: MentionType): string { + const style = TYPE_STYLE[type] ?? TYPE_STYLE['user']; + return cn(style.light, style.dark); +} diff --git a/src/components/shared/MiniChat.tsx b/src/components/shared/MiniChat.tsx new file mode 100644 index 0000000..bc0600b --- /dev/null +++ b/src/components/shared/MiniChat.tsx @@ -0,0 +1,218 @@ +'use client'; + +/** + * Compact inline chat component for embedding in file browsers and PR diffs. + * Shows recent messages from a room + a text input for quick replies. + * + * Usage: + * + */ + +import { useState, useRef, useEffect, useCallback } from 'react'; +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; +import { RoomWsClient, type RoomWsStatus } from '@/lib/room-ws-client'; +import { ContentRenderer } from '@/components/shared/ContentRenderer'; +import { Avatar, AvatarFallback } from '@/components/ui/avatar'; +import { Button } from '@/components/ui/button'; +import { Send } from 'lucide-react'; +import { cn } from '@/lib/utils'; + +interface MiniChatProps { + /** Room ID to load messages from */ + roomId: string; + /** WebSocket client for this room (optional — falls back to REST) */ + wsClient?: RoomWsClient | null; + /** Base URL for code reference navigation */ + repoBaseUrl?: string; + /** Branch for code references */ + branch?: string; + /** Height of the message list */ + maxHeight?: number; + /** Called when a message mention is clicked */ + onMentionClick?: (type: string, id: string, label: string) => void; + className?: string; +} + +interface ChatMessage { + id: string; + sender_type: string; + sender_id?: string | null; + sender_name?: string | null; + content: string; + content_type: string; + send_at: string; + display_name?: string | null; +} + +function formatTime(dateStr: string) { + const d = new Date(dateStr); + const now = new Date(); + const diff = Math.floor((now.getTime() - d.getTime()) / 1000); + if (diff < 60) return 'just now'; + if (diff < 3600) return `${Math.floor(diff / 60)}m ago`; + if (diff < 86400) return `${Math.floor(diff / 3600)}h ago`; + return d.toLocaleDateString(); +} + +export function MiniChat({ + roomId, + wsClient, + repoBaseUrl, + branch = 'main', + maxHeight = 320, + onMentionClick, + className, +}: MiniChatProps) { + const queryClient = useQueryClient(); + const [input, setInput] = useState(''); + const [wsReady, setWsReady] = useState(false); + const bottomRef = useRef(null); + const inputRef = useRef(null); + + // Ensure WS is connected — use public getStatus() and onStatusChange callback + useEffect(() => { + if (!wsClient) return; + if (wsClient.getStatus() === 'open') { setWsReady(true); return; } + const onStatusChange = (status: RoomWsStatus) => setWsReady(status === 'open'); + wsClient.updateCallbacks({ onStatusChange }); + return () => { + wsClient.updateCallbacks({ onStatusChange: undefined }); + }; + }, [wsClient]); + + const { data: messagesData, isLoading } = useQuery({ + queryKey: ['mini-chat-messages', roomId], + queryFn: async () => { + if (wsClient && wsReady) { + try { + return await wsClient.messageListWs(roomId, { limit: 20 }); + } catch { + // fall through to REST + } + } + // REST fallback + return wsClient?.messageList(roomId, { limit: 20 }) ?? null; + }, + enabled: true, + staleTime: 10_000, + }); + + const messages: ChatMessage[] = messagesData?.messages ?? []; + + // Auto-scroll to bottom on new messages + useEffect(() => { + bottomRef.current?.scrollIntoView({ behavior: 'smooth' }); + }, [messages.length]); + + const sendMutation = useMutation({ + mutationFn: async (body: string) => { + if (wsClient) { + await wsClient.messageCreate(roomId, body, { contentType: 'text' }); + } else { + const { messageCreate } = await import('@/client'); + await messageCreate({ path: { room_id: roomId }, body: { content: body, content_type: 'text' } }); + } + }, + onSuccess: () => { + setInput(''); + queryClient.invalidateQueries({ queryKey: ['mini-chat-messages', roomId] }); + inputRef.current?.focus(); + }, + }); + + const handleSend = useCallback(() => { + const body = input.trim(); + if (!body) return; + sendMutation.mutate(body); + }, [input, sendMutation]); + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === 'Enter' && !e.shiftKey) { + e.preventDefault(); + handleSend(); + } + }; + + return ( +
    + {/* Header */} +
    + Chat + {roomId} + {!wsReady && ( + REST mode + )} +
    + + {/* Messages */} +
    + {isLoading ? ( +
    +
    +
    + ) : messages.length === 0 ? ( +
    +

    No messages yet

    +
    + ) : ( +
    + {messages.map((msg) => ( +
    +
    + + + {(msg.display_name ?? msg.sender_id ?? '?')[0]?.toUpperCase()} + + + + {msg.display_name ?? msg.sender_id ?? 'Unknown'} + + + {formatTime(msg.send_at)} + +
    +
    + +
    +
    + ))} +
    +
    + )} +
    + + {/* Input */} +
    +