Repository navigation
feat: introduce Nova sidebar and code block components for enhanced file management: #325
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,80 @@ | ||
| "use client" | ||
|
|
||
| import { useMemo, useState } from "react" | ||
| import { Check, Copy } from "lucide-react" | ||
| import { cn } from "@/lib/utils" | ||
|
|
||
| interface CodeBlockProps { | ||
| code: string | ||
| language?: string | ||
| className?: string | ||
| maxHeight?: number | ||
| } | ||
|
|
||
| export function CodeBlock({ code, language, className, maxHeight = 384 }: CodeBlockProps) { | ||
| const [wrapped, setWrapped] = useState(false) | ||
| const [copied, setCopied] = useState(false) | ||
|
|
||
| const lineCount = useMemo(() => (code ? code.split("\n").length : 0), [code]) | ||
|
|
||
| const handleCopy = async () => { | ||
| try { | ||
| await navigator.clipboard.writeText(code) | ||
| setCopied(true) | ||
| window.setTimeout(() => setCopied(false), 1600) | ||
| } catch { | ||
| // Clipboard API unavailable (insecure context) — no-op. | ||
| } | ||
| } | ||
|
|
||
| return ( | ||
| <div | ||
| className={cn( | ||
| "group/code relative overflow-hidden rounded-lg border border-border bg-[#0d0d0d]", | ||
| className | ||
| )} | ||
| > | ||
| {/* Toolbar */} | ||
| <div className="flex h-9 items-center justify-between gap-2 border-b border-border/60 bg-muted/40 px-3"> | ||
| <div className="flex min-w-0 items-center gap-2"> | ||
| {language ? ( | ||
| <span className="shrink-0 rounded bg-muted px-1.5 py-0.5 font-mono text-[10px] font-medium uppercase tracking-wider text-muted-foreground"> | ||
| {language} | ||
| </span> | ||
| ) : null} | ||
| <span className="truncate font-mono text-[11px] text-muted-foreground/70"> | ||
| {lineCount} {lineCount === 1 ? "line" : "lines"} | ||
| </span> | ||
| </div> | ||
| <div className="flex shrink-0 items-center gap-1"> | ||
| <button | ||
| type="button" | ||
| onClick={() => setWrapped((v) => !v)} | ||
| aria-pressed={wrapped} | ||
| className="rounded-md px-2 py-1 text-[11px] font-medium text-muted-foreground transition-colors hover:bg-muted/60 hover:text-foreground" | ||
| > | ||
| {wrapped ? "No wrap" : "Wrap"} | ||
| </button> | ||
| <button | ||
| type="button" | ||
| onClick={handleCopy} | ||
| className="flex items-center gap-1 rounded-md px-2 py-1 text-[11px] font-medium text-muted-foreground transition-colors hover:bg-muted/60 hover:text-foreground" | ||
| > | ||
| {copied ? <Check className="size-3 text-emerald-500" /> : <Copy className="size-3" />} | ||
| {copied ? "Copied" : "Copy"} | ||
| </button> | ||
| </div> | ||
| </div> | ||
| {/* Code */} | ||
| <pre | ||
| className={cn( | ||
| "overflow-auto p-3 font-mono text-[12.5px] leading-relaxed text-foreground/85", | ||
| wrapped ? "whitespace-pre-wrap break-words" : "whitespace-pre" | ||
| )} | ||
| style={maxHeight ? { maxHeight } : undefined} | ||
| > | ||
| <code>{code}</code> | ||
| </pre> | ||
| </div> | ||
| ) | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,85 @@ | ||
| "use client" | ||
|
|
||
| import { useState } from "react" | ||
| import { File as FileIcon, FileCode2, FileJson } from "lucide-react" | ||
| import { cn } from "@/lib/utils" | ||
| import { CodeBlock } from "./code-block" | ||
|
|
||
| export type FileChangeStatus = "added" | "modified" | "deleted" | "renamed" | ||
|
|
||
| export interface FileCardData { | ||
| path: string | ||
| content?: string | ||
| status?: FileChangeStatus | ||
| language?: string | ||
| } | ||
|
|
||
| const STATUS_STYLES: Record<FileChangeStatus, { label: string; className: string }> = { | ||
| added: { label: "A", className: "bg-emerald-500/15 text-emerald-500" }, | ||
| modified: { label: "M", className: "bg-amber-500/15 text-amber-500" }, | ||
| deleted: { label: "D", className: "bg-red-500/15 text-red-500" }, | ||
| renamed: { label: "R", className: "bg-blue-500/15 text-blue-500" }, | ||
| } | ||
|
|
||
| const CODE_EXTS = new Set([ | ||
| "ts", "tsx", "js", "jsx", "mjs", "cjs", "py", "go", "rs", "swift", "kt", "kts", | ||
| "c", "cc", "cpp", "h", "hpp", "java", "rb", "php", "sh", "bash", "zsh", "sql", | ||
| "css", "scss", "html", "md", "mdx", "yml", "yaml", "toml", "graphql", "vue", "svelte", | ||
| ]) | ||
|
|
||
| function iconForPath(path: string) { | ||
| const ext = path.split(".").pop()?.toLowerCase() ?? "" | ||
| if (ext === "json") return <FileJson className="size-4" /> | ||
| if (CODE_EXTS.has(ext)) return <FileCode2 className="size-4" /> | ||
| return <FileIcon className="size-4" /> | ||
| } | ||
|
|
||
| export function FileCard({ path, content, status, language }: FileCardData) { | ||
| const [open, setOpen] = useState(Boolean(content)) | ||
| const parts = path.split("/") | ||
| const basename = parts[parts.length - 1] || path | ||
| const dir = parts.slice(0, -1).join("/") | ||
| const statusStyle = status ? STATUS_STYLES[status] : null | ||
| const expandable = content != null | ||
|
|
||
| return ( | ||
| <article className="overflow-hidden rounded-xl border border-border bg-card/40 transition-colors hover:border-border/80"> | ||
| <button | ||
| type="button" | ||
| onClick={() => expandable && setOpen((v) => !v)} | ||
| disabled={!expandable} | ||
| aria-expanded={expandable ? open : undefined} | ||
| className={cn( | ||
| "flex w-full items-center gap-2.5 px-3 py-2.5 text-left", | ||
| expandable ? "cursor-pointer transition-colors hover:bg-muted/20" : "cursor-default" | ||
| )} | ||
| > | ||
| <span className="shrink-0 text-muted-foreground/70">{iconForPath(path)}</span> | ||
| <span className="min-w-0 flex-1"> | ||
| <span className="block truncate text-[13px] font-medium text-foreground">{basename}</span> | ||
| <span className="block truncate font-mono text-[11px] text-muted-foreground/60" title={dir}> | ||
| {dir || "/"} | ||
| </span> | ||
| </span> | ||
| {language ? ( | ||
| <span className="hidden shrink-0 rounded bg-muted px-1.5 py-0.5 font-mono text-[10px] uppercase tracking-wide text-muted-foreground sm:inline"> | ||
| {language} | ||
| </span> | ||
| ) : null} | ||
| {statusStyle ? ( | ||
| <span | ||
| className={cn("shrink-0 rounded px-1.5 py-0.5 font-mono text-[10px] font-bold", statusStyle.className)} | ||
| title={status} | ||
| > | ||
| {statusStyle.label} | ||
| </span> | ||
| ) : null} | ||
| </button> | ||
| {content && open ? ( | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win Render the expandable body for empty files. When 🤖 Prompt for AI Agents |
||
| <div className="border-t border-border/60"> | ||
| <CodeBlock code={content} language={language} maxHeight={320} /> | ||
| </div> | ||
| ) : null} | ||
| </article> | ||
| ) | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,144 @@ | ||
| "use client" | ||
|
|
||
| import { useCallback, type KeyboardEvent as ReactKeyboardEvent } from "react" | ||
| import { X } from "lucide-react" | ||
| import { cn } from "@/lib/utils" | ||
| import { useMediaQuery } from "@/hooks/use-media-query" | ||
| import { useResizableSidebar } from "@/hooks/use-sidebar-resize" | ||
| import { FileCard, type FileCardData } from "./file-card" | ||
|
|
||
| export interface NovaSidebarProps { | ||
| files: FileCardData[] | ||
| open?: boolean | ||
| onOpenChange?: (open: boolean) => void | ||
| title?: string | ||
| } | ||
|
|
||
| const MIN_WIDTH = 280 | ||
| const MAX_WIDTH = 640 | ||
|
|
||
| export function NovaSidebar({ files, open = true, onOpenChange, title = "Session files" }: NovaSidebarProps) { | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The new |
||
| const isDesktop = useMediaQuery("(min-width: 1024px)") | ||
| const { width, setWidth, isResizing, onPointerDown } = useResizableSidebar({ | ||
| initialWidth: 384, | ||
| minWidth: MIN_WIDTH, | ||
| maxWidth: MAX_WIDTH, | ||
| storageKey: "nova:right-sidebar-width", | ||
| }) | ||
|
|
||
| // Clamp to the viewport so a stored width never overflows a small window. | ||
| const safeWidth = typeof window === "undefined" ? width : Math.min(width, window.innerWidth) | ||
|
|
||
| const handleClose = useCallback(() => onOpenChange?.(false), [onOpenChange]) | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win Do not show an inoperative close control. When 🤖 Prompt for AI Agents |
||
|
|
||
| const handleResizeKeyDown = useCallback( | ||
| (event: ReactKeyboardEvent<HTMLDivElement>) => { | ||
| if (event.key === "ArrowLeft") { | ||
| event.preventDefault() | ||
| setWidth((w) => w - 16) | ||
| } else if (event.key === "ArrowRight") { | ||
| event.preventDefault() | ||
| setWidth((w) => w + 16) | ||
| } | ||
| }, | ||
| [setWidth] | ||
| ) | ||
|
|
||
| // Desktop: inline, resizable panel. Hidden entirely when closed. | ||
| if (isDesktop) { | ||
| if (!open) return null | ||
| return ( | ||
| <aside | ||
| className="relative flex h-full shrink-0 flex-col overflow-hidden border-l border-border bg-background" | ||
| style={{ width: safeWidth }} | ||
| aria-label={title} | ||
| data-resizing={isResizing || undefined} | ||
| > | ||
| {/* Resize handle on the left edge */} | ||
| <div | ||
| role="separator" | ||
| aria-orientation="vertical" | ||
| aria-label="Resize sidebar" | ||
| tabIndex={0} | ||
| onPointerDown={onPointerDown} | ||
| onKeyDown={handleResizeKeyDown} | ||
| className={cn( | ||
| "absolute -left-1.5 top-0 z-10 h-full w-3 cursor-col-resize touch-none select-none outline-none", | ||
| "after:absolute after:left-1/2 after:top-0 after:h-full after:w-px after:-translate-x-1/2 after:transition-colors", | ||
| isResizing | ||
| ? "after:bg-primary" | ||
| : "after:bg-transparent hover:after:bg-primary/40 focus-visible:after:bg-primary/60" | ||
| )} | ||
| /> | ||
| <SidebarHeader title={title} count={files.length} onClose={handleClose} /> | ||
| <FileList files={files} isResizing={isResizing} /> | ||
| </aside> | ||
| ) | ||
| } | ||
|
|
||
| // Mobile / tablet: overlay drawer. | ||
| return ( | ||
| <> | ||
| {open ? ( | ||
| <div | ||
| className="fixed inset-0 z-40 bg-black/50 backdrop-blur-sm" | ||
| onClick={handleClose} | ||
| aria-hidden="true" | ||
| /> | ||
| ) : null} | ||
| <aside | ||
| className={cn( | ||
| "fixed inset-y-0 right-0 z-50 flex flex-col overflow-hidden border-l border-border bg-background shadow-2xl transition-transform duration-300 ease-out", | ||
| open ? "translate-x-0" : "translate-x-full" | ||
| )} | ||
| style={{ width: "min(85vw, 400px)" }} | ||
| aria-label={title} | ||
| aria-hidden={!open} | ||
| > | ||
|
Comment on lines
+89
to
+97
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🎯 Functional Correctness | 🟠 Major | 🏗️ Heavy lift Manage keyboard focus for the mobile drawer. When the drawer opens, focus stays behind the overlay, and keyboard users can continue tabbing through the covered page. When it closes, its off-screen controls remain focusable despite 🤖 Prompt for AI Agents |
||
| <SidebarHeader title={title} count={files.length} onClose={handleClose} /> | ||
| <FileList files={files} isResizing={false} /> | ||
| </aside> | ||
| </> | ||
| ) | ||
| } | ||
|
|
||
| function SidebarHeader({ title, count, onClose }: { title: string; count: number; onClose: () => void }) { | ||
| return ( | ||
| <header className="flex h-12 shrink-0 items-center gap-2 border-b border-border px-4"> | ||
| <h2 className="min-w-0 flex-1 truncate text-sm font-semibold text-foreground">{title}</h2> | ||
| <span className="shrink-0 rounded-full border border-border bg-muted/30 px-2 py-0.5 text-[11px] font-medium tabular-nums text-muted-foreground"> | ||
| {count} | ||
| </span> | ||
| <button | ||
| type="button" | ||
| onClick={onClose} | ||
| className="shrink-0 rounded-md p-1.5 text-muted-foreground transition-colors hover:bg-muted/40 hover:text-foreground" | ||
| aria-label={`Close ${title}`} | ||
| > | ||
| <X className="size-4" /> | ||
| </button> | ||
| </header> | ||
| ) | ||
| } | ||
|
|
||
| function FileList({ files, isResizing }: { files: FileCardData[]; isResizing: boolean }) { | ||
| if (files.length === 0) { | ||
| return ( | ||
| <div className="flex min-h-0 flex-1 items-center justify-center px-6 text-center"> | ||
| <p className="text-xs leading-relaxed text-muted-foreground/70">No files in this session yet.</p> | ||
| </div> | ||
| ) | ||
| } | ||
| return ( | ||
| <div | ||
| className={cn( | ||
| "min-h-0 flex-1 space-y-2 overflow-y-auto overscroll-contain p-3", | ||
| isResizing && "pointer-events-none" | ||
| )} | ||
| > | ||
| {files.map((file) => ( | ||
| <FileCard key={file.path} {...file} /> | ||
| ))} | ||
| </div> | ||
| ) | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,18 @@ | ||
| "use client" | ||
|
|
||
| import { useEffect, useState } from "react" | ||
|
|
||
| export function useMediaQuery(query: string): boolean { | ||
| const [matches, setMatches] = useState(false) | ||
|
|
||
| useEffect(() => { | ||
| const mql = window.matchMedia(query) | ||
| const onChange = (event: MediaQueryListEvent) => setMatches(event.matches) | ||
|
|
||
| setMatches(mql.matches) | ||
| mql.addEventListener("change", onChange) | ||
| return () => mql.removeEventListener("change", onChange) | ||
| }, [query]) | ||
|
|
||
| return matches | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win
Replace and clean up the copy-feedback timer.
If the user copies twice within 1.6 seconds, the first timeout clears the second copy’s “Copied” feedback early. Cancel the previous timeout before scheduling another. Cancel the remaining timeout on unmount. Based on learnings: React timers need cleanup when the component releases them.
🤖 Prompt for AI Agents
Source: Learnings