diff --git a/app/demo/gist-viewer/page.tsx b/app/demo/gist-viewer/page.tsx
new file mode 100644
index 0000000..efe2320
--- /dev/null
+++ b/app/demo/gist-viewer/page.tsx
@@ -0,0 +1,177 @@
+"use client";
+
+import { GistViewer } from "@/components/gist-viewer";
+import type { File } from "@/types";
+
+export default function GistViewerDemo() {
+ const sampleFiles: File[] = [
+ {
+ name: "hello.js",
+ content: `// A simple JavaScript example
+function greet(name) {
+ return \`Hello, \${name}!\`;
+}
+
+console.log(greet("World"));
+console.log(greet("GhostPaste"));
+
+// Export for module usage
+export { greet };`,
+ language: "javascript",
+ },
+ {
+ name: "styles.css",
+ content: `/* Modern CSS Reset */
+*, *::before, *::after {
+ box-sizing: border-box;
+}
+
+* {
+ margin: 0;
+}
+
+body {
+ line-height: 1.5;
+ -webkit-font-smoothing: antialiased;
+}
+
+img, picture, video, canvas, svg {
+ display: block;
+ max-width: 100%;
+}
+
+input, button, textarea, select {
+ font: inherit;
+}
+
+p, h1, h2, h3, h4, h5, h6 {
+ overflow-wrap: break-word;
+}`,
+ language: "css",
+ },
+ {
+ name: "index.html",
+ content: `
+
+
+
+
+ GhostPaste Demo
+
+
+
+
+
Welcome to GhostPaste
+
Share code snippets securely with zero-knowledge encryption.
+
+
+
+`,
+ language: "html",
+ },
+ {
+ name: "README.md",
+ content: `# GhostPaste Demo
+
+This is a demo of the GistViewer component.
+
+## Features
+
+- Syntax highlighting for multiple languages
+- Tab navigation for multiple files
+- Copy to clipboard functionality
+- Download files individually or all at once
+- Toggle line numbers and word wrap
+- Responsive design
+
+## Usage
+
+\`\`\`tsx
+import { GistViewer } from "@/components/gist-viewer";
+
+function MyComponent() {
+ const files = [
+ { name: "file.js", content: "code here", language: "javascript" }
+ ];
+
+ return ;
+}
+\`\`\``,
+ language: "markdown",
+ },
+ ];
+
+ const manyFiles: File[] = Array.from({ length: 8 }, (_, i) => ({
+ name: `component-${i + 1}.tsx`,
+ content: `import React from 'react';
+
+export function Component${i + 1}() {
+ return (
+
+
Component ${i + 1}
+
This is component number ${i + 1}
+
+ );
+}`,
+ language: "typescript",
+ }));
+
+ return (
+
+
+
GistViewer Component Demo
+
+ A read-only viewer for displaying code snippets with syntax
+ highlighting, file navigation, and download capabilities.
+
+
+
+
+ Basic Example (4 files)
+
+ Files are displayed in a vertical layout with individual copy and
+ download buttons.
+
+
+
+
+
+
+
+ Many Files Example (8 files)
+
+ The vertical layout scales well for any number of files.
+
+
+
+
+
+
+
+ Empty State
+
+ When no files are provided, a friendly message is shown.
+
+
+
+
+
+
+
+ Features
+
+ - Syntax highlighting powered by CodeMirror
+ - Vertical file layout with clear file headers
+ - Individual copy and download buttons per file
+ - Download all files at once
+ - Toggle line numbers on/off
+ - Toggle word wrap on/off
+ - Responsive design for mobile devices
+ - Dark mode support
+ - Keyboard accessible
+ - Clean, GitHub-style presentation
+
+
+
+ );
+}
diff --git a/components/gist-viewer.test.tsx b/components/gist-viewer.test.tsx
new file mode 100644
index 0000000..9a7fe24
--- /dev/null
+++ b/components/gist-viewer.test.tsx
@@ -0,0 +1,272 @@
+import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
+import { render, screen, fireEvent, waitFor } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import { GistViewer } from "./gist-viewer";
+import type { File } from "@/types";
+
+// Mock next-themes
+vi.mock("next-themes", () => ({
+ useTheme: () => ({ resolvedTheme: "light" }),
+ ThemeProvider: ({ children }: { children: React.ReactNode }) => children,
+}));
+
+// Mock CodeEditor component
+vi.mock("@/components/ui/code-editor", () => ({
+ CodeEditor: ({
+ value,
+ language,
+ readOnly,
+ }: {
+ value: string;
+ language?: string;
+ readOnly?: boolean;
+ }) => (
+
+ ),
+}));
+
+// Mock URL.createObjectURL
+global.URL.createObjectURL = vi.fn(() => "blob:mock-url");
+global.URL.revokeObjectURL = vi.fn();
+
+// Mock clipboard API globally
+const mockWriteText = vi.fn();
+Object.defineProperty(global.navigator, "clipboard", {
+ value: {
+ writeText: mockWriteText,
+ },
+ writable: true,
+ configurable: true,
+});
+
+describe("GistViewer", () => {
+ const mockFiles: File[] = [
+ {
+ name: "example.js",
+ content: "console.log('Hello, world!');",
+ language: "javascript",
+ },
+ {
+ name: "styles.css",
+ content: "body { margin: 0; }",
+ language: "css",
+ },
+ {
+ name: "index.html",
+ content: "Hello
",
+ language: "html",
+ },
+ ];
+
+ beforeEach(() => {
+ vi.clearAllMocks();
+ mockWriteText.mockClear();
+ });
+
+ afterEach(() => {
+ vi.restoreAllMocks();
+ });
+
+ it("renders with no files", () => {
+ render();
+ expect(screen.getByText("No files to display")).toBeInTheDocument();
+ });
+
+ it("renders all files in vertical layout", () => {
+ render();
+
+ // Check all file names are displayed
+ expect(screen.getByText("example.js")).toBeInTheDocument();
+ expect(screen.getByText("styles.css")).toBeInTheDocument();
+ expect(screen.getByText("index.html")).toBeInTheDocument();
+
+ // Check all file contents are displayed
+ const codeContents = screen.getAllByTestId("code-content");
+ expect(codeContents).toHaveLength(3);
+ expect(codeContents[0]).toHaveTextContent("console.log('Hello, world!');");
+ expect(codeContents[1]).toHaveTextContent("body { margin: 0; }");
+ expect(codeContents[2]).toHaveTextContent("Hello
");
+ });
+
+ it("displays file icons for each file", () => {
+ render();
+
+ // Should have a file icon for each file
+ const fileHeaders = screen.getAllByText(
+ /example\.js|styles\.css|index\.html/
+ );
+ expect(fileHeaders).toHaveLength(3);
+
+ // Each file should have its name displayed
+ expect(screen.getByText("example.js")).toBeInTheDocument();
+ expect(screen.getByText("styles.css")).toBeInTheDocument();
+ expect(screen.getByText("index.html")).toBeInTheDocument();
+ });
+
+ it("toggles line numbers", () => {
+ render();
+
+ const lineNumbersBtn = screen.getByRole("button", {
+ name: /Line Numbers: On/i,
+ });
+ fireEvent.click(lineNumbersBtn);
+
+ expect(
+ screen.getByRole("button", { name: /Line Numbers: Off/i })
+ ).toBeInTheDocument();
+ });
+
+ it("toggles word wrap", () => {
+ render();
+
+ const wordWrapBtn = screen.getByRole("button", { name: /Word Wrap: Off/i });
+ fireEvent.click(wordWrapBtn);
+
+ expect(
+ screen.getByRole("button", { name: /Word Wrap: On/i })
+ ).toBeInTheDocument();
+ });
+
+ it("copies file content to clipboard", async () => {
+ render();
+
+ // Find the copy button using test ID
+ const copyButton = screen.getByTestId("copy-example.js");
+
+ // Simulate clicking the button directly
+ fireEvent.click(copyButton);
+
+ // Wait for async clipboard operation
+ await waitFor(() => {
+ expect(mockWriteText).toHaveBeenCalledTimes(1);
+ expect(mockWriteText).toHaveBeenCalledWith(
+ "console.log('Hello, world!');"
+ );
+ });
+ });
+
+ it("downloads individual file", async () => {
+ const user = userEvent.setup();
+ const mockClick = vi.fn();
+ // Mock createElement to track download
+ const originalCreateElement = document.createElement.bind(document);
+ const createElementSpy = vi.spyOn(document, "createElement");
+ createElementSpy.mockImplementation((tagName) => {
+ const element = originalCreateElement(tagName);
+ if (tagName === "a") {
+ element.click = mockClick;
+ }
+ return element;
+ });
+
+ // Spy on appendChild and removeChild without mocking
+ const appendChildSpy = vi.spyOn(document.body, "appendChild");
+ const removeChildSpy = vi.spyOn(document.body, "removeChild");
+
+ render();
+
+ // Find the download button for the first file
+ const downloadButton = screen.getByRole("button", {
+ name: /Download example.js/i,
+ });
+
+ // Click the download button
+ await user.click(downloadButton);
+
+ expect(mockClick).toHaveBeenCalled();
+ expect(appendChildSpy).toHaveBeenCalled();
+ expect(removeChildSpy).toHaveBeenCalled();
+
+ const appendedElements = appendChildSpy.mock.calls.map((call) => call[0]);
+ const anchor = appendedElements.find(
+ (el) => (el as HTMLElement).tagName === "A"
+ ) as HTMLAnchorElement | undefined;
+ expect(anchor).toBeDefined();
+ expect(anchor?.download).toBe("example.js");
+ expect(anchor?.href).toBe("blob:mock-url");
+ });
+
+ it("downloads all files", async () => {
+ const user = userEvent.setup();
+ const mockClick = vi.fn();
+
+ // Mock createElement for download
+ const originalCreateElement = document.createElement.bind(document);
+ vi.spyOn(document, "createElement").mockImplementation((tagName) => {
+ const element = originalCreateElement(tagName);
+ if (tagName === "a") {
+ element.click = mockClick;
+ }
+ return element;
+ });
+
+ render();
+
+ const downloadAllBtn = screen.getByRole("button", {
+ name: /download all/i,
+ });
+ await user.click(downloadAllBtn);
+
+ // Should trigger download for each file
+ expect(mockClick).toHaveBeenCalledTimes(3);
+ });
+
+ it("renders with custom className", () => {
+ const { container } = render(
+
+ );
+
+ expect(container.firstChild).toHaveClass("custom-class");
+ });
+
+ it("shows CodeEditor in read-only mode", () => {
+ render();
+
+ const codeEditors = screen.getAllByTestId("code-editor");
+ codeEditors.forEach((editor) => {
+ expect(editor).toHaveAttribute("data-readonly", "true");
+ });
+ });
+
+ it("handles missing language gracefully", () => {
+ const filesWithoutLang: File[] = [
+ {
+ name: "notes.txt",
+ content: "Some plain text",
+ },
+ ];
+
+ render();
+
+ expect(screen.getByText("Some plain text")).toBeInTheDocument();
+ expect(screen.getByTestId("code-language")).toHaveTextContent("");
+ });
+
+ it("displays multiple files with same content correctly", () => {
+ const duplicateFiles: File[] = [
+ {
+ name: "file1.js",
+ content: "const x = 1;",
+ language: "javascript",
+ },
+ {
+ name: "file2.js",
+ content: "const x = 1;",
+ language: "javascript",
+ },
+ ];
+
+ render();
+
+ expect(screen.getByText("file1.js")).toBeInTheDocument();
+ expect(screen.getByText("file2.js")).toBeInTheDocument();
+
+ const codeContents = screen.getAllByTestId("code-content");
+ expect(codeContents).toHaveLength(2);
+ expect(codeContents[0]).toHaveTextContent("const x = 1;");
+ expect(codeContents[1]).toHaveTextContent("const x = 1;");
+ });
+});
diff --git a/components/gist-viewer.tsx b/components/gist-viewer.tsx
new file mode 100644
index 0000000..3b44100
--- /dev/null
+++ b/components/gist-viewer.tsx
@@ -0,0 +1,187 @@
+"use client";
+
+import { useState } from "react";
+import { Button } from "@/components/ui/button";
+import { CodeEditor } from "@/components/ui/code-editor";
+import { Copy, Download, FileText } from "lucide-react";
+import { useTheme } from "next-themes";
+import {
+ Tooltip,
+ TooltipContent,
+ TooltipProvider,
+ TooltipTrigger,
+} from "@/components/ui/tooltip";
+import { cn } from "@/lib/utils";
+import type { File } from "@/types";
+
+export interface GistViewerProps {
+ files: File[];
+ className?: string;
+}
+
+export function GistViewer({ files, className }: GistViewerProps) {
+ const [showLineNumbers, setShowLineNumbers] = useState(true);
+ const [wordWrap, setWordWrap] = useState(false);
+ const { resolvedTheme } = useTheme();
+
+ const handleCopyFile = async (content: string) => {
+ try {
+ await navigator.clipboard.writeText(content);
+ // TODO: Show toast notification
+ } catch (error) {
+ console.error("Failed to copy:", error);
+ }
+ };
+
+ const handleDownloadFile = (file: File) => {
+ const blob = new Blob([file.content], { type: "text/plain" });
+ const url = URL.createObjectURL(blob);
+ const a = document.createElement("a");
+ a.href = url;
+ a.download = file.name;
+ document.body.appendChild(a);
+ a.click();
+ document.body.removeChild(a);
+ URL.revokeObjectURL(url);
+ };
+
+ const handleDownloadAll = () => {
+ // For now, download as individual files
+ // TODO: Implement ZIP download for multiple files
+ files.forEach((file) => handleDownloadFile(file));
+ };
+
+ if (files.length === 0) {
+ return (
+
+ );
+ }
+
+ return (
+
+
+ {/* Toolbar */}
+
+
+
+
+
+
+
+
+ {/* Files List - Vertical Layout */}
+
+ {files.map((file) => (
+ handleCopyFile(file.content)}
+ onDownload={() => handleDownloadFile(file)}
+ />
+ ))}
+
+
+
+ );
+}
+
+interface FileContentProps {
+ file: File;
+ theme: "light" | "dark";
+ showLineNumbers: boolean;
+ wordWrap: boolean;
+ onCopy: () => void;
+ onDownload: () => void;
+}
+
+function FileContent({
+ file,
+ theme,
+ showLineNumbers,
+ wordWrap,
+ onCopy,
+ onDownload,
+}: FileContentProps) {
+ return (
+
+ {/* File Header */}
+
+
+
+ {file.name}
+
+
+
+
+
+
+
+ Copy to clipboard
+
+
+
+
+
+
+ Download file
+
+
+
+
+ {/* Code Editor */}
+
+
+ );
+}
diff --git a/components/ui/tooltip.tsx b/components/ui/tooltip.tsx
new file mode 100644
index 0000000..95e0faa
--- /dev/null
+++ b/components/ui/tooltip.tsx
@@ -0,0 +1,61 @@
+"use client";
+
+import * as React from "react";
+import * as TooltipPrimitive from "@radix-ui/react-tooltip";
+
+import { cn } from "@/lib/utils";
+
+function TooltipProvider({
+ delayDuration = 0,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ );
+}
+
+function Tooltip({
+ ...props
+}: React.ComponentProps) {
+ return (
+
+
+
+ );
+}
+
+function TooltipTrigger({
+ ...props
+}: React.ComponentProps) {
+ return ;
+}
+
+function TooltipContent({
+ className,
+ sideOffset = 0,
+ children,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+
+ {children}
+
+
+
+ );
+}
+
+export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider };
diff --git a/docs/PHASE_4_ISSUE_TRACKING.md b/docs/PHASE_4_ISSUE_TRACKING.md
index e01253e..377cc6d 100644
--- a/docs/PHASE_4_ISSUE_TRACKING.md
+++ b/docs/PHASE_4_ISSUE_TRACKING.md
@@ -10,7 +10,7 @@ All 19 Phase 4 UI component issues have been successfully created on GitHub. Thi
| GitHub # | Component | Priority | Status | Description |
| -------- | ------------- | -------- | ----------- | -------------------------------------------- |
-| #53 | Header | HIGH | 🟡 Ready | Main header with navigation and theme toggle |
+| #53 | Header | HIGH | 🟢 Complete | Main header with navigation and theme toggle |
| #70 | Footer | LOW | 🟡 Ready | Simple footer with links and copyright |
| #62 | Container | MEDIUM | 🟡 Ready | Reusable container for consistent spacing |
| #57 | Design Tokens | HIGH | 🟢 Complete | Design system tokens for theming |
@@ -21,8 +21,8 @@ All 19 Phase 4 UI component issues have been successfully created on GitHub. Thi
| -------- | --------------- | -------- | ----------- | ----------------------------------- |
| #54 | CodeEditor | CRITICAL | 🟢 Complete | CodeMirror 6 wrapper component |
| #55 | FileEditor | CRITICAL | 🟢 Complete | Single file editor with metadata |
-| #56 | MultiFileEditor | CRITICAL | 🟡 Ready | Container for multiple file editors |
-| #63 | AddFileButton | MEDIUM | 🟡 Ready | Button to add new files |
+| #56 | MultiFileEditor | CRITICAL | 🟢 Complete | Container for multiple file editors |
+| #63 | AddFileButton | MEDIUM | 🟢 Complete | Button to add new files |
### Form Components (3 issues)
@@ -34,13 +34,13 @@ All 19 Phase 4 UI component issues have been successfully created on GitHub. Thi
### Display Components (5 issues)
-| GitHub # | Component | Priority | Status | Description |
-| -------- | -------------- | -------- | -------- | ------------------------------------ |
-| #61 | GistViewer | HIGH | 🟡 Ready | Read-only gist viewer |
-| #66 | FileList | MEDIUM | 🟡 Ready | File navigation tabs/list |
-| #71 | VersionHistory | LOW | 🟡 Ready | Version history dropdown |
-| #67 | LoadingStates | MEDIUM | 🟡 Ready | Consistent loading components |
-| #58 | ErrorBoundary | HIGH | 🟡 Ready | Error boundary for graceful failures |
+| GitHub # | Component | Priority | Status | Description |
+| -------- | -------------- | -------- | ----------- | ------------------------------------ |
+| #61 | GistViewer | HIGH | 🟢 Complete | Read-only gist viewer |
+| #66 | FileList | MEDIUM | 🟡 Ready | File navigation tabs/list |
+| #71 | VersionHistory | LOW | 🟡 Ready | Version history dropdown |
+| #67 | LoadingStates | MEDIUM | 🟡 Ready | Consistent loading components |
+| #58 | ErrorBoundary | HIGH | 🟡 Ready | Error boundary for graceful failures |
### UI Features (3 issues)
@@ -59,12 +59,12 @@ Note: Issue #69 appears to be a duplicate of #59 (both for copy to clipboard fun
1. **#57** - Design Tokens (Foundation for all components) ✅ COMPLETE
2. **#54** - CodeEditor (Core editing functionality) ✅ COMPLETE
3. **#55** - FileEditor (Builds on CodeEditor) ✅ COMPLETE
-4. **#56** - MultiFileEditor (Manages FileEditors)
+4. **#56** - MultiFileEditor (Manages FileEditors) ✅ COMPLETE
### Week 2: Essential Components
-5. **#53** - Header (Navigation)
-6. **#61** - GistViewer (View functionality)
+5. **#53** - Header (Navigation) ✅ COMPLETE
+6. **#61** - GistViewer (View functionality) ✅ COMPLETE
7. **#60** - ShareDialog (Sharing flow)
8. **#58** - ErrorBoundary (Error handling)
9. **#59** - Copy to Clipboard (Core feature)
@@ -72,7 +72,7 @@ Note: Issue #69 appears to be a duplicate of #59 (both for copy to clipboard fun
### Week 3: Supporting Components
10. **#62** - Container (Layout consistency)
-11. **#63** - AddFileButton (File management)
+11. **#63** - AddFileButton (File management) ✅ COMPLETE
12. **#64** - ExpirySelector (Gist options)
13. **#65** - PINInput (Security feature)
14. **#66** - FileList (Navigation)
@@ -87,9 +87,9 @@ Note: Issue #69 appears to be a duplicate of #59 (both for copy to clipboard fun
## Priority Summary
-- **CRITICAL** (3): #54, #55, #56
-- **HIGH** (6): #53, #57, #58, #59, #60, #61
-- **MEDIUM** (8): #62, #63, #64, #65, #66, #67, #68
+- **CRITICAL** (3): #54 ✅, #55 ✅, #56 ✅
+- **HIGH** (6): #53 ✅, #57 ✅, #58, #59, #60, #61 ✅
+- **MEDIUM** (7): #62, #63 ✅, #64, #65, #66, #67, #68
- **LOW** (3): #70, #71, #72
## Status Legend
@@ -102,9 +102,16 @@ Note: Issue #69 appears to be a duplicate of #59 (both for copy to clipboard fun
## Notes
1. **Duplicate Issue**: #69 appears to duplicate #59 (copy to clipboard). Consider closing one.
-2. **Dependencies**: Components in Week 1 must be completed before Week 2 can fully proceed.
+2. **Dependencies**: Components in Week 1 must be completed before Week 2 can fully proceed. ✅ Week 1 is now complete!
3. **Parallel Work**: Multiple developers can work on different component groups simultaneously.
-4. **In Progress**: #54 (CodeEditor) - PR #75 submitted, awaiting review.
+4. **Completed PRs**:
+ - #54 (CodeEditor) - PR #75 ✅
+ - #55 (FileEditor) - PR #77 ✅
+ - #56 (MultiFileEditor) - PR #79 ✅
+ - #57 (Design Tokens) - PR #76 ✅
+ - #53 (Header) - PR #83 ✅
+ - #63 (AddFileButton) - Implemented in PR #78 ✅
+ - #61 (GistViewer) - In progress on feat/gist-viewer branch ✅
## Quick Commands
@@ -122,4 +129,21 @@ gh issue edit [number] --add-label "in progress"
gh pr create --title "feat: implement [component]" --body "Closes #[number]"
```
+## Progress Summary
+
+- **Completed**: 7 out of 19 issues (37%)
+ - All CRITICAL issues are complete ✅
+ - 3 out of 6 HIGH priority issues complete
+ - 1 out of 7 MEDIUM priority issues complete
+- **Remaining**: 12 issues
+ - 3 HIGH priority
+ - 6 MEDIUM priority
+ - 3 LOW priority
+
+### Next Priority Issues
+
+1. **#60** - ShareDialog (HIGH) - Essential for sharing flow
+2. **#58** - ErrorBoundary (HIGH) - Important for error handling
+3. **#59** - Copy to Clipboard (HIGH) - Core feature
+
Last Updated: 2025-01-07
diff --git a/docs/TODO.md b/docs/TODO.md
index 0d656e1..c9690b3 100644
--- a/docs/TODO.md
+++ b/docs/TODO.md
@@ -117,7 +117,7 @@ This document tracks the implementation progress of GhostPaste. Check off tasks
### Display Components
-- [ ] Create GistViewer component - [#61](https://github.com/nullcoder/ghostpaste/issues/61)
+- [x] Create GistViewer component - [#61](https://github.com/nullcoder/ghostpaste/issues/61)
- [ ] Create FileList component (vertical display for viewing) - [#66](https://github.com/nullcoder/ghostpaste/issues/66)
- [ ] Create VersionHistory dropdown - [#71](https://github.com/nullcoder/ghostpaste/issues/71)
- [ ] Create LoadingStates component - [#67](https://github.com/nullcoder/ghostpaste/issues/67)
diff --git a/package-lock.json b/package-lock.json
index 8e5375b..985f515 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -24,6 +24,7 @@
"@radix-ui/react-select": "^2.2.5",
"@radix-ui/react-slot": "^1.2.3",
"@radix-ui/react-tabs": "^1.1.12",
+ "@radix-ui/react-tooltip": "^1.2.7",
"@uiw/codemirror-theme-github": "^4.23.12",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
@@ -11236,6 +11237,40 @@
}
}
},
+ "node_modules/@radix-ui/react-tooltip": {
+ "version": "1.2.7",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-tooltip/-/react-tooltip-1.2.7.tgz",
+ "integrity": "sha512-Ap+fNYwKTYJ9pzqW+Xe2HtMRbQ/EeWkj2qykZ6SuEV4iS/o1bZI5ssJbk4D2r8XuDuOBVz/tIx2JObtuqU+5Zw==",
+ "license": "MIT",
+ "dependencies": {
+ "@radix-ui/primitive": "1.1.2",
+ "@radix-ui/react-compose-refs": "1.1.2",
+ "@radix-ui/react-context": "1.1.2",
+ "@radix-ui/react-dismissable-layer": "1.1.10",
+ "@radix-ui/react-id": "1.1.1",
+ "@radix-ui/react-popper": "1.2.7",
+ "@radix-ui/react-portal": "1.1.9",
+ "@radix-ui/react-presence": "1.1.4",
+ "@radix-ui/react-primitive": "2.1.3",
+ "@radix-ui/react-slot": "1.2.3",
+ "@radix-ui/react-use-controllable-state": "1.2.2",
+ "@radix-ui/react-visually-hidden": "1.2.3"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "@types/react-dom": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
+ "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ },
+ "@types/react-dom": {
+ "optional": true
+ }
+ }
+ },
"node_modules/@radix-ui/react-use-callback-ref": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.1.tgz",
diff --git a/package.json b/package.json
index 8cacc0d..3b2afd7 100644
--- a/package.json
+++ b/package.json
@@ -41,6 +41,7 @@
"@radix-ui/react-select": "^2.2.5",
"@radix-ui/react-slot": "^1.2.3",
"@radix-ui/react-tabs": "^1.1.12",
+ "@radix-ui/react-tooltip": "^1.2.7",
"@uiw/codemirror-theme-github": "^4.23.12",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",