diff --git a/build/icon.icns b/build/icon.icns index dc1680fc..ea0da748 100644 Binary files a/build/icon.icns and b/build/icon.icns differ diff --git a/build/icon.ico b/build/icon.ico index c82bca26..4fa57362 100644 Binary files a/build/icon.ico and b/build/icon.ico differ diff --git a/build/icon.png b/build/icon.png index d1d65ac6..e344d359 100644 Binary files a/build/icon.png and b/build/icon.png differ diff --git a/build/new-icon/icon.icns b/build/new-icon/icon.icns new file mode 100644 index 00000000..dc1680fc Binary files /dev/null and b/build/new-icon/icon.icns differ diff --git a/build/new-icon/icon.ico b/build/new-icon/icon.ico new file mode 100644 index 00000000..c82bca26 Binary files /dev/null and b/build/new-icon/icon.ico differ diff --git a/build/new-icon/icon.png b/build/new-icon/icon.png new file mode 100644 index 00000000..d1d65ac6 Binary files /dev/null and b/build/new-icon/icon.png differ diff --git a/docs/docs.json b/docs/docs.json index 54f07ef2..38267c00 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -61,6 +61,7 @@ "openspec-commands", "history", "context-management", + "document-graph", "autorun-playbooks", "playbook-exchange", "git-worktrees", diff --git a/docs/document-graph.md b/docs/document-graph.md new file mode 100644 index 00000000..9d2ae820 --- /dev/null +++ b/docs/document-graph.md @@ -0,0 +1,143 @@ +--- +title: Document Graph +description: Visualize markdown file relationships and wiki-link connections in an interactive graph view. +icon: diagram-project +--- + +The Document Graph provides an interactive visualization of your markdown files and their connections. See how documents link to each other through wiki-links (`[[link]]`) and standard markdown links, making it easy to understand your documentation structure at a glance. + +![Document Graph](./screenshots/document-graph.png) + +## Opening the Document Graph + +There are several ways to access the Document Graph: + +### From the File Explorer + +Click the **graph icon** (circular arrows) in the Files tab header to open the Document Graph for your current project. + +![Last Graph Button](./screenshots/document-graph-last-graph.png) + +### From Quick Actions + +Press `Cmd+K` / `Ctrl+K` and search for "Document Graph" to open it directly. + +### From File Preview + +When viewing a markdown file in File Preview, press `Cmd+Shift+G` / `Ctrl+Shift+G` to open the Document Graph focused on that file. Press `Esc` to return to the File Preview. + +### Using Go to File + +Press `Cmd+G` / `Ctrl+G` to open the fuzzy file finder, navigate to any markdown file, then use `Cmd+Shift+G` to jump to the Document Graph from there. + +## Navigating the Graph + +The Document Graph is designed for keyboard-first navigation: + +| Action | Key | +|--------|-----| +| Navigate to connected nodes | `Arrow Keys` (spatial detection) | +| Focus/select a node | `Enter` | +| Open the selected document | `O` | +| Close the graph | `Esc` | +| Cycle through connected nodes | `Tab` | + +### Mouse Controls + +- **Click** a node to select it +- **Double-click** a node to recenter the view on it +- **Drag** nodes to reposition them — positions are saved +- **Scroll** to zoom in and out +- **Pan** by dragging the background + +## Graph Controls + +The toolbar at the top of the Document Graph provides several options: + +### Depth Control + +Adjust the **Depth** setting to control how many levels of connections are shown from the focused document: + +- **Depth: 1** — Show only direct connections +- **Depth: 2** — Show connections and their connections (default) +- **Depth: 3+** — Show deeper relationship chains + +Lower depth values keep the graph focused; higher values reveal the full document ecosystem. + +### External Links + +Toggle **External** to show or hide external URL links found in your documents: + +- **Enabled** — External links appear as separate domain nodes (e.g., "github.com", "docs.example.com") +- **Disabled** — Only internal document relationships are shown + +External link nodes help you see which external resources your documentation references. + +### Search + +Use the search box to filter documents by name. Matching documents are highlighted in the graph. + +## Understanding the Graph + +### Node Types + +- **Document nodes** — Your markdown files, showing the filename and a preview of content +- **External link nodes** — Domains of external URLs referenced in your documents +- **Focused node** — The currently selected document (highlighted with a different border) + +### Edge Types + +Lines between nodes represent different types of connections: + +- **Wiki-links** — `[[document-name]]` style links +- **Markdown links** — `[text](path/to/file.md)` style links +- **External links** — Links to URLs outside your project + +### Node Information + +Each document node displays: + +- **Filename** — The document name +- **Folder indicator** — Shows the parent directory (e.g., "docs") +- **Content preview** — A snippet of the document's content + +## Tips for Effective Use + +### Workflow Integration + +1. Use `Cmd+G` to quickly find a file +2. Open it in File Preview to read or edit +3. Press `Cmd+Shift+G` to see its connections in the Document Graph +4. Press `O` to open a connected document +5. Press `Esc` to return to File Preview + +### Large Documentation Sets + +For projects with many markdown files: + +- Start with **Depth: 1** to see immediate connections +- Increase depth gradually to explore relationships +- Use **Search** to find specific documents quickly +- Drag nodes to organize the view — positions persist + +### Understanding Documentation Structure + +The Document Graph is especially useful for: + +- **Auditing links** — Find orphaned documents with no incoming links +- **Understanding navigation** — See how documents connect for readers +- **Planning restructuring** — Visualize the impact of moving or renaming files +- **Onboarding** — Help new team members understand documentation architecture + +## Keyboard Shortcut Summary + +| Action | macOS | Windows/Linux | +|--------|-------|---------------| +| Open Document Graph | Via `Cmd+K` menu | Via `Ctrl+K` menu | +| Open from File Preview | `Cmd+Shift+G` | `Ctrl+Shift+G` | +| Go to File (fuzzy finder) | `Cmd+G` | `Ctrl+G` | +| Navigate nodes | `Arrow Keys` | `Arrow Keys` | +| Select/focus node | `Enter` | `Enter` | +| Open document | `O` | `O` | +| Cycle connected nodes | `Tab` | `Tab` | +| Close graph | `Esc` | `Esc` | diff --git a/docs/features.md b/docs/features.md index 6ed15d18..7876b1de 100644 --- a/docs/features.md +++ b/docs/features.md @@ -23,6 +23,7 @@ icon: sparkles - 📋 **Session Discovery** - Automatically discovers and imports existing sessions from all supported providers, including conversations from before Maestro was installed. Browse, search, star, rename, and resume any session. - 🔀 **Git Integration** - Automatic repo detection, branch display, diff viewer, commit logs, and git-aware file completion. Work with git without leaving the app. - 📁 **[File Explorer](./general-usage)** - Browse project files with syntax highlighting, markdown preview, and image viewing. Reference files in prompts with `@` mentions. +- 🕸️ **[Document Graph](./document-graph)** - Visualize markdown file relationships and wiki-link connections in an interactive graph. Navigate with keyboard shortcuts, adjust depth, and see how your documentation connects. - 🔍 **[Powerful Output Filtering](./general-usage)** - Search and filter AI output with include/exclude modes, regex support, and per-response local filters. - ⚡ **[Slash Commands](./slash-commands)** - Extensible command system with autocomplete. Create custom commands with template variables for your workflows. Includes bundled [Spec-Kit](./speckit-commands) for feature specifications and [OpenSpec](./openspec-commands) for change proposals. - 💾 **Draft Auto-Save** - Never lose work. Drafts are automatically saved and restored per session. diff --git a/docs/group-chat.md b/docs/group-chat.md index 5117aaf6..20559ffe 100644 --- a/docs/group-chat.md +++ b/docs/group-chat.md @@ -14,6 +14,7 @@ Group Chat lets you coordinate multiple AI agents in a single conversation. A mo - **Architecture discussions**: Get perspectives from agents with different codebase contexts - **Comparative analysis**: "Compare the testing approach in these three repositories" - **Knowledge synthesis**: Combine expertise from specialized agents +- **Cross-machine collaboration**: Coordinate agents running on different machines via [SSH Remote Execution](./ssh-remote-execution) ## How It Works @@ -57,9 +58,30 @@ Moderator: "Here's how they relate: Next steps: Would you like details on any specific integration?" ``` +## Remote Agents in Group Chat + +Group Chat works seamlessly with [SSH Remote Execution](./ssh-remote-execution). You can mix local and remote agents in the same conversation: + +![Group Chat with Remote Agents](./screenshots/group-chat-over-ssh.png) + +**Supported configurations:** +- Local moderator with remote participants +- Remote moderator with local participants +- Any mix of local and remote agents +- Agents spread across multiple SSH hosts + +Remote agents are identified by the **REMOTE** pill in the participant list. Each agent works in their own environment — the moderator coordinates across machines transparently. + +**Use cases for remote Group Chat:** +- Compare implementations across development and production environments +- Get perspectives from agents with access to different servers +- Coordinate changes that span multiple machines +- Synthesize information from agents with different tool installations + ## Tips for Effective Group Chats - **Name agents descriptively** - Agent names appear in the chat, so "Frontend-React" is clearer than "Agent1" - **Be specific in questions** - The more context you provide, the better the moderator can route - **@mention explicitly** - You can direct questions to specific agents: "What does @Backend think?" - **Let the moderator work** - It may take multiple rounds for complex questions +- **Mix local and remote** - Combine agents across machines for maximum coverage diff --git a/docs/keyboard-shortcuts.md b/docs/keyboard-shortcuts.md index 31f1c2de..dfc952e3 100644 --- a/docs/keyboard-shortcuts.md +++ b/docs/keyboard-shortcuts.md @@ -124,15 +124,16 @@ In AI mode, use `@` to reference files in your prompts: ## Navigation & Search -| Action | Key | -|--------|-----| -| Navigate Agents | `Up/Down Arrow` while in sidebar | -| Select Agent | `Enter` while in sidebar | -| Open Session Filter | `Cmd+F` while in sidebar | -| Navigate Files | `Up/Down Arrow` while in file tree | -| Open File Tree Filter | `Cmd+F` while in file tree | -| Open File Preview | `Enter` on selected file | -| Close Preview/Filter/Modal | `Esc` | +| Action | macOS | Windows/Linux | +|--------|-------|---------------| +| Go to File (fuzzy finder) | `Cmd+G` | `Ctrl+G` | +| Navigate Agents | `Up/Down Arrow` while in sidebar | `Up/Down Arrow` while in sidebar | +| Select Agent | `Enter` while in sidebar | `Enter` while in sidebar | +| Open Session Filter | `Cmd+F` while in sidebar | `Ctrl+F` while in sidebar | +| Navigate Files | `Up/Down Arrow` while in file tree | `Up/Down Arrow` while in file tree | +| Open File Tree Filter | `Cmd+F` while in file tree | `Ctrl+F` while in file tree | +| Open File Preview | `Enter` on selected file | `Enter` on selected file | +| Close Preview/Filter/Modal | `Esc` | `Esc` | ## File Preview @@ -140,9 +141,20 @@ In AI mode, use `@` to reference files in your prompts: |--------|-------|---------------| | Copy File Path | `Cmd+P` | `Ctrl+P` | | Open Search | `Cmd+F` | `Ctrl+F` | +| Open Document Graph | `Cmd+Shift+G` | `Ctrl+Shift+G` | | Scroll | `Up/Down Arrow` | `Up/Down Arrow` | | Close | `Esc` | `Esc` | +## Document Graph + +| Action | Key | +|--------|-----| +| Navigate to connected nodes | `Arrow Keys` | +| Focus/select a node | `Enter` | +| Open the selected document | `O` | +| Cycle through connected nodes | `Tab` | +| Close the graph | `Esc` | + ## Customizing Shortcuts Most shortcuts can be remapped to fit your workflow: diff --git a/docs/screenshots/document-graph-last-graph.png b/docs/screenshots/document-graph-last-graph.png new file mode 100644 index 00000000..94e69c4e Binary files /dev/null and b/docs/screenshots/document-graph-last-graph.png differ diff --git a/docs/screenshots/document-graph.png b/docs/screenshots/document-graph.png new file mode 100644 index 00000000..b21a8665 Binary files /dev/null and b/docs/screenshots/document-graph.png differ diff --git a/docs/screenshots/group-chat-over-ssh.png b/docs/screenshots/group-chat-over-ssh.png new file mode 100644 index 00000000..f2d042c9 Binary files /dev/null and b/docs/screenshots/group-chat-over-ssh.png differ diff --git a/docs/screenshots/ssh-agents-mapping.png b/docs/screenshots/ssh-agents-mapping.png new file mode 100644 index 00000000..9b174ce8 Binary files /dev/null and b/docs/screenshots/ssh-agents-mapping.png differ diff --git a/docs/screenshots/ssh-agents-servers.png b/docs/screenshots/ssh-agents-servers.png new file mode 100644 index 00000000..6974b0d0 Binary files /dev/null and b/docs/screenshots/ssh-agents-servers.png differ diff --git a/docs/screenshots/ssh-agents-status.png b/docs/screenshots/ssh-agents-status.png new file mode 100644 index 00000000..182497de Binary files /dev/null and b/docs/screenshots/ssh-agents-status.png differ diff --git a/docs/ssh-remote-execution.md b/docs/ssh-remote-execution.md index 250b5a6e..6f61538a 100644 --- a/docs/ssh-remote-execution.md +++ b/docs/ssh-remote-execution.md @@ -15,16 +15,20 @@ SSH Remote Execution wraps agent commands in SSH, executing them on a configured - Access tools or SDKs installed only on specific servers - Work with codebases that require particular OS or architecture - Execute agents in secure/isolated environments +- Coordinate multiple agents across different machines in [Group Chat](./group-chat) +- Run Auto Run playbooks on remote projects ## Configuring SSH Remotes ### Adding a Remote Host 1. Open **Settings** (`Cmd+,` / `Ctrl+,`) -2. Scroll to the **SSH Remote Hosts** section under Remote Execution +2. Navigate to the **SSH Hosts** tab 3. Click **Add SSH Remote** 4. Configure the connection: +![SSH Remote Hosts Settings](./screenshots/ssh-agents-servers.png) + | Field | Description | |-------|-------------| | **Name** | Display name for this remote (e.g., "Dev Server", "GPU Box") | @@ -118,9 +122,11 @@ Each agent can have its own SSH remote setting, overriding the global default. ### Configuring an Agent 1. Open the agent's configuration panel (gear icon in session header, or via Settings → Agents) -2. Find the **SSH Remote** dropdown +2. Find the **SSH Remote Execution** dropdown 3. Select an option: +![SSH Agent Mapping](./screenshots/ssh-agents-mapping.png) + | Option | Behavior | |--------|----------| | **Use Global Default** | Follows the global setting (shows which remote if one is set) | @@ -139,11 +145,64 @@ When spawning an agent, Maestro resolves which SSH remote to use: ## Status Visibility -When a session is running via SSH remote: -- The session displays the remote host name in the status area +When a session is running via SSH remote, you can easily identify it: + +![SSH Agent Status](./screenshots/ssh-agents-status.png) + +- **REMOTE pill** — Appears in the Left Bar next to the agent, indicating it's configured for remote execution +- **Host name badge** — Displayed in the Main Panel header showing which SSH host the agent is running on (e.g., "PEDTOME") +- **Agent type indicator** — Shows "claude-code (SSH)" to clarify the execution mode - Connection state reflects SSH connectivity - Errors are detected and displayed with SSH-specific context +## Full Remote Capabilities + +Remote agents support all the features you'd expect from local agents: + +### Remote File System Access + +The File Explorer works seamlessly with remote agents: +- Browse files and directories on the remote host +- Open and edit files directly +- Use `@` file mentions to reference remote files in prompts + +### Remote Auto Run + +Run Auto Run playbooks on remote projects: +- Auto Run documents can reference files on the remote host +- Task execution happens on the remote machine +- Progress and results stream back to Maestro in real-time + +### Remote Git Worktrees + +Create and manage git worktrees on remote repositories: +- Worktree sub-agents run on the same remote host +- Branch isolation works just like local worktrees +- PR creation connects to the remote repository + +### Remote Command Terminal + +The Command Terminal executes commands on the remote host: +- Full PTY support for interactive commands +- Tab completion works with remote file paths +- Command history is preserved per-session + +### Group Chat with Remote Agents + +Remote agents can participate in Group Chat alongside local agents. This enables powerful cross-machine collaboration: + +![Group Chat with SSH Agents](./screenshots/group-chat-over-ssh.png) + +- Mix local and remote agents in the same conversation +- The moderator can be local or remote +- Each agent works in their own environment (local or remote) +- Synthesize information across different machines and codebases + +This is especially useful for: +- Comparing implementations across different environments +- Coordinating changes that span multiple servers +- Getting perspectives from agents with access to different resources + ## Troubleshooting ### Authentication Errors diff --git a/src/__tests__/renderer/components/DocumentGraph/GraphLegend.test.tsx b/src/__tests__/renderer/components/DocumentGraph/GraphLegend.test.tsx index 2ee3491f..02b109f9 100644 --- a/src/__tests__/renderer/components/DocumentGraph/GraphLegend.test.tsx +++ b/src/__tests__/renderer/components/DocumentGraph/GraphLegend.test.tsx @@ -1,8 +1,10 @@ /** * Tests for the GraphLegend component * - * The GraphLegend displays a collapsible panel explaining node types, edge types, - * keyboard shortcuts, and interaction hints in the Mind Map visualization. + * The GraphLegend displays a sliding panel explaining node types, edge types, + * keyboard shortcuts, and interaction hints in the Document Graph visualization. + * + * The panel is always shown (no collapsed state) and can be closed via onClose callback. */ import { describe, it, expect, vi } from 'vitest'; @@ -55,31 +57,32 @@ const lightTheme: Theme = { const defaultProps: GraphLegendProps = { theme: mockTheme, showExternalLinks: true, + onClose: vi.fn(), }; describe('GraphLegend', () => { describe('Rendering', () => { - it('renders in collapsed state by default', () => { + it('renders as a sliding panel with Help heading', () => { render(); - // Should show the header button - expect(screen.getByRole('button', { name: /legend/i })).toBeInTheDocument(); + // Should show the Help heading + expect(screen.getByText('Help')).toBeInTheDocument(); - // Should NOT show the content sections - expect(screen.queryByText('Node Types')).not.toBeInTheDocument(); - }); - - it('renders in expanded state when defaultExpanded is true', () => { - render(); - - // Should show the content sections + // Should show all content sections expect(screen.getByText('Node Types')).toBeInTheDocument(); expect(screen.getByText('Connection Types')).toBeInTheDocument(); expect(screen.getByText('Selection')).toBeInTheDocument(); }); + it('has correct aria label on the panel', () => { + render(); + + const panel = screen.getByRole('region', { name: /help panel/i }); + expect(panel).toBeInTheDocument(); + }); + it('renders all node types when showExternalLinks is true', () => { - render(); + render(); expect(screen.getByText('Document')).toBeInTheDocument(); // External Link appears in both Node Types and Connection Types sections @@ -88,7 +91,7 @@ describe('GraphLegend', () => { }); it('hides external node type when showExternalLinks is false', () => { - render(); + render(); expect(screen.getByText('Document')).toBeInTheDocument(); // External Link should appear once in "Connection Types" section for edges @@ -98,460 +101,330 @@ describe('GraphLegend', () => { }); it('renders all edge types when showExternalLinks is true', () => { - render(); + render(); expect(screen.getByText('Internal Link')).toBeInTheDocument(); // External Link appears in both Node Types and Connection Types sections - const allExternalLinks = screen.getAllByText('External Link'); - expect(allExternalLinks.length).toBe(2); // One in nodes, one in edges + expect(screen.getAllByText('External Link').length).toBe(2); }); it('hides external edge type when showExternalLinks is false', () => { - render(); + render(); expect(screen.getByText('Internal Link')).toBeInTheDocument(); + // External Link edge type should not be shown + const connectionTypesSection = screen.getByText('Connection Types').parentElement; + expect(within(connectionTypesSection!).queryByText('External Link')).not.toBeInTheDocument(); + }); + }); - // External Link should not appear at all when external links are disabled - expect(screen.queryByText('External Link')).not.toBeInTheDocument(); + describe('Close Button', () => { + it('renders close button with correct title', () => { + render(); + + const closeButton = screen.getByTitle('Close (Esc)'); + expect(closeButton).toBeInTheDocument(); }); - it('renders selection section with selected node preview', () => { - render(); + it('calls onClose when close button is clicked', () => { + const onClose = vi.fn(); + render(); - expect(screen.getByText('Selection')).toBeInTheDocument(); - expect(screen.getByText('Selected Node')).toBeInTheDocument(); - expect(screen.getByText('Connected Edge')).toBeInTheDocument(); + const closeButton = screen.getByTitle('Close (Esc)'); + fireEvent.click(closeButton); + + expect(onClose).toHaveBeenCalledOnce(); }); + }); - it('renders keyboard shortcuts section', () => { - render(); + describe('Keyboard Shortcuts Section', () => { + it('shows keyboard shortcuts section', () => { + render(); expect(screen.getByText('Keyboard Shortcuts')).toBeInTheDocument(); + }); + + it('displays navigation shortcut', () => { + render(); + expect(screen.getByText('↑ ↓ ← →')).toBeInTheDocument(); expect(screen.getByText('Navigate between nodes')).toBeInTheDocument(); + }); + + it('displays enter shortcut', () => { + render(); + expect(screen.getByText('Enter')).toBeInTheDocument(); expect(screen.getByText('Recenter on focused node')).toBeInTheDocument(); + }); + + it('displays open shortcut', () => { + render(); + expect(screen.getByText('O')).toBeInTheDocument(); expect(screen.getByText('Open file in preview')).toBeInTheDocument(); }); - it('renders interaction hints', () => { - render(); + it('displays search shortcut', () => { + render(); + expect(screen.getByText('⌘F')).toBeInTheDocument(); + expect(screen.getByText('Focus search')).toBeInTheDocument(); + }); + + it('displays escape shortcut', () => { + render(); + + expect(screen.getByText('Esc')).toBeInTheDocument(); + expect(screen.getByText('Close panel / modal')).toBeInTheDocument(); + }); + }); + + describe('Mouse Actions Section', () => { + it('shows mouse actions section', () => { + render(); + + expect(screen.getByText('Mouse Actions')).toBeInTheDocument(); + }); + + it('displays click action', () => { + render(); + + expect(screen.getByText('Click')).toBeInTheDocument(); + expect(screen.getByText('Select node')).toBeInTheDocument(); + }); + + it('displays double-click action', () => { + render(); + + expect(screen.getByText('Double-click')).toBeInTheDocument(); expect(screen.getByText('Recenter view')).toBeInTheDocument(); + }); + + it('displays right-click action', () => { + render(); + + expect(screen.getByText('Right-click')).toBeInTheDocument(); expect(screen.getByText('Context menu')).toBeInTheDocument(); + }); + + it('displays drag action', () => { + render(); + + expect(screen.getByText('Drag')).toBeInTheDocument(); + expect(screen.getByText('Reposition node')).toBeInTheDocument(); + }); + + it('displays scroll action', () => { + render(); + + expect(screen.getByText('Scroll')).toBeInTheDocument(); expect(screen.getByText('Zoom in/out')).toBeInTheDocument(); }); - - it('renders status indicators section', () => { - render(); - - expect(screen.getByText('Status Indicators')).toBeInTheDocument(); - expect(screen.getByText('Broken Links')).toBeInTheDocument(); - expect(screen.getByText('Links to non-existent files')).toBeInTheDocument(); - }); }); - describe('Toggle Behavior', () => { - it('expands when header is clicked', () => { + describe('Selection Section', () => { + it('shows selection section', () => { render(); - // Initially collapsed - expect(screen.queryByText('Node Types')).not.toBeInTheDocument(); - - // Click to expand - fireEvent.click(screen.getByRole('button', { name: /legend/i })); - - // Should now show content - expect(screen.getByText('Node Types')).toBeInTheDocument(); + expect(screen.getByText('Selection')).toBeInTheDocument(); }); - it('collapses when header is clicked again', () => { - render(); - - // Initially expanded - expect(screen.getByText('Node Types')).toBeInTheDocument(); - - // Click to collapse - fireEvent.click(screen.getByRole('button', { name: /legend/i })); - - // Should no longer show content - expect(screen.queryByText('Node Types')).not.toBeInTheDocument(); - }); - - it('toggles multiple times correctly', () => { + it('displays selected node info', () => { render(); - const button = screen.getByRole('button', { name: /legend/i }); - - // First click: expand - fireEvent.click(button); - expect(screen.getByText('Node Types')).toBeInTheDocument(); - - // Second click: collapse - fireEvent.click(button); - expect(screen.queryByText('Node Types')).not.toBeInTheDocument(); - - // Third click: expand again - fireEvent.click(button); - expect(screen.getByText('Node Types')).toBeInTheDocument(); - }); - }); - - describe('Accessibility', () => { - it('has region role with aria-label', () => { - render(); - - // Mind map legend uses "Mind map legend" as aria-label - expect(screen.getByRole('region', { name: /mind map legend/i })).toBeInTheDocument(); - }); - - it('toggle button has aria-expanded attribute', () => { - render(); - - const button = screen.getByRole('button', { name: /legend/i }); - expect(button).toHaveAttribute('aria-expanded', 'false'); - }); - - it('aria-expanded updates when toggled', () => { - render(); - - const button = screen.getByRole('button', { name: /legend/i }); - expect(button).toHaveAttribute('aria-expanded', 'false'); - - fireEvent.click(button); - expect(button).toHaveAttribute('aria-expanded', 'true'); - - fireEvent.click(button); - expect(button).toHaveAttribute('aria-expanded', 'false'); - }); - - it('toggle button has aria-controls referencing content', () => { - render(); - - const button = screen.getByRole('button', { name: /legend/i }); - expect(button).toHaveAttribute('aria-controls', 'legend-content'); - }); - - it('content container has matching id', () => { - render(); - - expect(document.getElementById('legend-content')).toBeInTheDocument(); - }); - - it('node previews have aria-label', () => { - render(); - - // Document node card appears in Node Types and Selection (as selected) - const docNodes = screen.getAllByRole('img', { name: /document node card/i }); - expect(docNodes.length).toBeGreaterThanOrEqual(1); - expect(screen.getByRole('img', { name: /external link node pill/i })).toBeInTheDocument(); - expect(screen.getByRole('img', { name: /document node card \(selected\)/i })).toBeInTheDocument(); - }); - - it('edge previews have aria-label', () => { - render(); - - // Internal link edge appears in Connection Types and Selection (as highlighted) - const internalEdges = screen.getAllByRole('img', { name: /internal link edge/i }); - expect(internalEdges.length).toBeGreaterThanOrEqual(1); - expect(screen.getByRole('img', { name: /external link edge/i })).toBeInTheDocument(); - expect(screen.getByRole('img', { name: /internal link edge \(highlighted\)/i })).toBeInTheDocument(); - }); - - it('broken links indicator has aria-label', () => { - render(); - - expect(screen.getByRole('img', { name: /broken links warning indicator/i })).toBeInTheDocument(); - }); - }); - - describe('Theme Styling', () => { - it('applies theme background color to container', () => { - const { container } = render(); - - const legend = container.querySelector('.graph-legend'); - expect(legend).toHaveStyle({ backgroundColor: mockTheme.colors.bgActivity }); - }); - - it('applies theme border color to container', () => { - const { container } = render(); - - const legend = container.querySelector('.graph-legend'); - // Border is a shorthand, check individual properties - expect(legend).toHaveStyle({ borderWidth: '1px', borderStyle: 'solid' }); - }); - - it('applies light theme colors correctly', () => { - const { container } = render(); - - const legend = container.querySelector('.graph-legend'); - expect(legend).toHaveStyle({ backgroundColor: lightTheme.colors.bgActivity }); - }); - - it('applies accent color to header background', () => { - render(); - - const button = screen.getByRole('button', { name: /legend/i }); - expect(button).toHaveStyle({ backgroundColor: `${mockTheme.colors.accent}10` }); - }); - - it('section headers use dim text color', () => { - render(); - - // Get all section headers - const nodeTypesHeader = screen.getByText('Node Types'); - expect(nodeTypesHeader).toHaveStyle({ color: mockTheme.colors.textDim }); - }); - - it('item labels use main text color', () => { - render(); - - const documentLabel = screen.getByText('Document'); - expect(documentLabel).toHaveStyle({ color: mockTheme.colors.textMain }); - }); - - it('item descriptions use dim text color with opacity', () => { - render(); - - const description = screen.getByText('Card with title and description'); - expect(description).toHaveStyle({ color: mockTheme.colors.textDim, opacity: '0.8' }); - }); - }); - - describe('Node Preview Styling (Mind Map Cards)', () => { - it('document node preview renders as SVG card with rect', () => { - render(); - - const docNode = screen.getByRole('img', { name: /^document node card$/i }); - const rect = docNode.querySelector('rect'); - expect(rect).toBeInTheDocument(); - // Card has rounded corners (rx=4) - expect(rect).toHaveAttribute('rx', '4'); - }); - - it('external node preview renders as pill-shaped SVG', () => { - render(); - - const extNode = screen.getByRole('img', { name: /^external link node pill$/i }); - const rect = extNode.querySelector('rect'); - expect(rect).toBeInTheDocument(); - // Pill has high rx value for rounded ends (rx=7) - expect(rect).toHaveAttribute('rx', '7'); - }); - - it('selected document node preview has accent stroke', () => { - render(); - - const selectedNode = screen.getByRole('img', { name: /document node card \(selected\)/i }); - const rect = selectedNode.querySelector('rect'); - expect(rect).toBeInTheDocument(); - // Selected nodes have accent stroke color - expect(rect).toHaveAttribute('stroke', mockTheme.colors.accent); - }); - - it('document node preview uses bgActivity fill color', () => { - render(); - - const docNode = screen.getByRole('img', { name: /^document node card$/i }); - const rect = docNode.querySelector('rect'); - expect(rect).toBeInTheDocument(); - expect(rect).toHaveAttribute('fill', mockTheme.colors.bgActivity); - }); - - it('external node preview uses bgMain fill color', () => { - render(); - - const extNode = screen.getByRole('img', { name: /^external link node pill$/i }); - const rect = extNode.querySelector('rect'); - expect(rect).toBeInTheDocument(); - expect(rect).toHaveAttribute('fill', mockTheme.colors.bgMain); - }); - }); - - describe('Edge Preview Styling (Bezier Curves)', () => { - it('internal edge preview uses bezier path', () => { - render(); - - const internalEdge = screen.getByRole('img', { name: /^internal link edge$/i }); - const path = internalEdge.querySelector('path'); - expect(path).toBeInTheDocument(); - // Should NOT have stroke-dasharray (solid line) - expect(path).not.toHaveAttribute('stroke-dasharray'); - }); - - it('external edge preview is dashed bezier path', () => { - render(); - - const externalEdge = screen.getByRole('img', { name: /external link edge(?! \(highlighted\))/i }); - const path = externalEdge.querySelector('path'); - expect(path).toBeInTheDocument(); - expect(path).toHaveAttribute('stroke-dasharray', '4 3'); - }); - - it('internal edge uses dim text color for stroke', () => { - render(); - - const internalEdge = screen.getByRole('img', { name: /^internal link edge$/i }); - const path = internalEdge.querySelector('path'); - expect(path).toHaveAttribute('stroke', mockTheme.colors.textDim); - }); - - it('highlighted edge uses accent color for stroke', () => { - render(); - - const highlightedEdge = screen.getByRole('img', { name: /internal link edge \(highlighted\)/i }); - const path = highlightedEdge.querySelector('path'); - expect(path).toHaveAttribute('stroke', mockTheme.colors.accent); - }); - - it('highlighted edge has thicker stroke width', () => { - render(); - - const highlightedEdge = screen.getByRole('img', { name: /internal link edge \(highlighted\)/i }); - const path = highlightedEdge.querySelector('path'); - expect(path).toHaveAttribute('stroke-width', '2'); - }); - - it('normal edge has thinner stroke width', () => { - render(); - - const normalEdge = screen.getByRole('img', { name: /^internal link edge$/i }); - const path = normalEdge.querySelector('path'); - expect(path).toHaveAttribute('stroke-width', '1.5'); - }); - }); - - describe('Container Styling', () => { - it('has correct CSS class for styling', () => { - const { container } = render(); - - expect(container.querySelector('.graph-legend')).toBeInTheDocument(); - }); - - it('is positioned absolutely at bottom center', () => { - const { container } = render(); - - const legend = container.querySelector('.graph-legend'); - expect(legend).toHaveClass('absolute'); - // Position is set via inline styles: bottom: 16, left: '50%', transform: 'translateX(-50%)' - expect(legend).toHaveStyle({ - bottom: '16px', - left: '50%', - transform: 'translateX(-50%)', - }); - }); - - it('has max-width constraint', () => { - const { container } = render(); - - const legend = container.querySelector('.graph-legend'); - expect(legend).toHaveStyle({ maxWidth: '300px' }); - }); - - it('has z-index for stacking above graph', () => { - const { container } = render(); - - const legend = container.querySelector('.graph-legend'); - expect(legend).toHaveStyle({ zIndex: '10' }); - }); - - it('has rounded corners', () => { - const { container } = render(); - - const legend = container.querySelector('.graph-legend'); - expect(legend).toHaveClass('rounded-lg'); - }); - - it('has shadow for elevation', () => { - const { container } = render(); - - const legend = container.querySelector('.graph-legend'); - expect(legend).toHaveClass('shadow-lg'); - }); - }); - - describe('Content Descriptions', () => { - it('document node has correct description', () => { - render(); - - expect(screen.getByText('Card with title and description')).toBeInTheDocument(); - }); - - it('external node has correct description', () => { - render(); - - expect(screen.getByText('Pill showing domain name')).toBeInTheDocument(); - }); - - it('internal edge has correct description', () => { - render(); - - expect(screen.getByText('Connection between markdown files')).toBeInTheDocument(); - }); - - it('external edge has correct description', () => { - render(); - - expect(screen.getByText('Connection to external domain')).toBeInTheDocument(); - }); - - it('selected node has correct description', () => { - render(); - + expect(screen.getByText('Selected Node')).toBeInTheDocument(); expect(screen.getByText('Click or navigate to select')).toBeInTheDocument(); }); - it('connected edge has correct description', () => { - render(); + it('displays connected edge info', () => { + render(); + expect(screen.getByText('Connected Edge')).toBeInTheDocument(); expect(screen.getByText('Edges to/from selected node')).toBeInTheDocument(); }); }); - describe('Chevron Icons', () => { - it('shows ChevronUp when collapsed (indicating can expand)', () => { - const { container } = render(); + describe('Status Indicators Section', () => { + it('shows status indicators section', () => { + render(); - // When collapsed, clicking expands - so show up chevron - const button = screen.getByRole('button', { name: /legend/i }); - const svgs = button.querySelectorAll('svg'); - expect(svgs.length).toBe(1); // Should have one chevron icon + expect(screen.getByText('Status Indicators')).toBeInTheDocument(); }); - it('shows ChevronDown when expanded (indicating can collapse)', () => { - const { container } = render(); + it('displays broken links indicator', () => { + render(); - const button = screen.getByRole('button', { name: /legend/i }); - const svgs = button.querySelectorAll('svg'); - expect(svgs.length).toBe(1); // Should have one chevron icon + expect(screen.getByText('Broken Links')).toBeInTheDocument(); + expect(screen.getByText('Links to non-existent files')).toBeInTheDocument(); + }); + + it('has accessible broken links indicator icon', () => { + render(); + + const indicator = screen.getByRole('img', { name: /broken links warning indicator/i }); + expect(indicator).toBeInTheDocument(); + }); + }); + + describe('Node Preview Icons', () => { + it('renders document node preview with aria-label', () => { + render(); + + const docPreviews = screen.getAllByRole('img', { name: /document node card/i }); + expect(docPreviews.length).toBeGreaterThanOrEqual(1); + }); + + it('renders external link node preview when showExternalLinks is true', () => { + render(); + + const extPreviews = screen.getAllByRole('img', { name: /external link node pill/i }); + expect(extPreviews.length).toBeGreaterThanOrEqual(1); + }); + + it('does not render external link node preview when showExternalLinks is false', () => { + render(); + + expect(screen.queryByRole('img', { name: /external link node pill/i })).not.toBeInTheDocument(); + }); + + it('renders selected document node preview', () => { + render(); + + // Selected node preview has (selected) in aria-label + const selectedPreviews = screen.getAllByRole('img', { name: /document node card \(selected\)/i }); + expect(selectedPreviews.length).toBeGreaterThanOrEqual(1); + }); + }); + + describe('Edge Preview Icons', () => { + it('renders internal link edge preview', () => { + render(); + + const internalEdges = screen.getAllByRole('img', { name: /internal link edge/i }); + expect(internalEdges.length).toBeGreaterThanOrEqual(1); + }); + + it('renders external link edge preview when showExternalLinks is true', () => { + render(); + + const externalEdges = screen.getAllByRole('img', { name: /external link edge/i }); + expect(externalEdges.length).toBeGreaterThanOrEqual(1); + }); + + it('does not render external link edge preview when showExternalLinks is false', () => { + render(); + + expect(screen.queryByRole('img', { name: /external link edge/i })).not.toBeInTheDocument(); + }); + + it('renders highlighted edge preview for connected edges', () => { + render(); + + // Highlighted edge preview has (highlighted) in aria-label + const highlightedEdges = screen.getAllByRole('img', { name: /link edge \(highlighted\)/i }); + expect(highlightedEdges.length).toBeGreaterThanOrEqual(1); + }); + }); + + describe('Theme Integration', () => { + it('applies theme background color to panel', () => { + const { container } = render(); + + const panel = container.querySelector('.graph-legend'); + expect(panel).toHaveStyle({ backgroundColor: mockTheme.colors.bgActivity }); + }); + + it('applies theme border color', () => { + const { container } = render(); + + const panel = container.querySelector('.graph-legend'); + expect(panel).toHaveStyle({ borderRight: `1px solid ${mockTheme.colors.border}` }); + }); + + it('applies theme text color to heading', () => { + render(); + + const heading = screen.getByText('Help'); + expect(heading).toHaveStyle({ color: mockTheme.colors.textMain }); + }); + + it('applies theme dim text color to section headers', () => { + render(); + + const nodeTypesHeader = screen.getByText('Node Types'); + expect(nodeTypesHeader).toHaveStyle({ color: mockTheme.colors.textDim }); + }); + + it('works with light theme', () => { + render(); + + const heading = screen.getByText('Help'); + expect(heading).toHaveStyle({ color: lightTheme.colors.textMain }); }); }); describe('Dynamic Content', () => { it('updates when showExternalLinks prop changes', () => { - const { rerender } = render(); + const { rerender } = render(); // Initially showing external links expect(screen.getAllByText('External Link').length).toBe(2); // Rerender with external links disabled - rerender(); + rerender(); // External Link should no longer appear expect(screen.queryByText('External Link')).not.toBeInTheDocument(); }); + }); - it('applies theme changes dynamically', () => { - const { container, rerender } = render(); + describe('Panel Layout', () => { + it('has correct width', () => { + const { container } = render(); - const legend = container.querySelector('.graph-legend'); - expect(legend).toHaveStyle({ backgroundColor: mockTheme.colors.bgActivity }); + const panel = container.querySelector('.graph-legend'); + expect(panel).toHaveStyle({ width: '280px' }); + }); - // Rerender with light theme - rerender(); + it('has correct z-index', () => { + const { container } = render(); - expect(legend).toHaveStyle({ backgroundColor: lightTheme.colors.bgActivity }); + const panel = container.querySelector('.graph-legend'); + expect(panel).toHaveStyle({ zIndex: 20 }); + }); + + it('is positioned at top-left', () => { + const { container } = render(); + + const panel = container.querySelector('.graph-legend'); + expect(panel).toHaveClass('top-0', 'left-0'); + }); + + it('has animation class for slide-in effect', () => { + const { container } = render(); + + const panel = container.querySelector('.graph-legend'); + expect(panel).toHaveClass('animate-in', 'slide-in-from-left'); }); }); + + describe('Content Organization', () => { + it('displays sections in correct order', () => { + const { container } = render(); + + const sections = container.querySelectorAll('h4'); + const sectionTexts = Array.from(sections).map(s => s.textContent); + + expect(sectionTexts).toEqual([ + 'Node Types', + 'Connection Types', + 'Selection', + 'Status Indicators', + 'Keyboard Shortcuts', + 'Mouse Actions', + ]); + }); + + }); }); diff --git a/src/__tests__/renderer/components/FileExplorerPanel.test.tsx b/src/__tests__/renderer/components/FileExplorerPanel.test.tsx index db164df2..c901c1a4 100644 --- a/src/__tests__/renderer/components/FileExplorerPanel.test.tsx +++ b/src/__tests__/renderer/components/FileExplorerPanel.test.tsx @@ -1204,11 +1204,12 @@ describe('FileExplorerPanel', () => { expect(screen.getByText('src')).toBeInTheDocument(); }); - it('handles very long cwd path', () => { + it('handles very long projectRoot path', () => { const longPath = '/Users/test/very/long/path/to/project/that/is/really/deep'; - const session = createMockSession({ cwd: longPath }); + const session = createMockSession({ projectRoot: longPath }); render(); + // FileExplorerPanel header uses projectRoot for the title attribute expect(screen.getByTitle(longPath)).toBeInTheDocument(); }); @@ -1359,30 +1360,30 @@ describe('FileExplorerPanel', () => { expect(screen.getByText('Reveal in Finder')).toBeInTheDocument(); }); - it('shows Focus in Graph option only for markdown files', () => { + it('shows Document Graph option only for markdown files', () => { const onFocusFileInGraph = vi.fn(); const { container } = render( ); - // Right-click on markdown file - should show Focus in Graph + // Right-click on markdown file - should show Document Graph const mdFile = Array.from(container.querySelectorAll('[data-file-index]')) .find(el => el.textContent?.includes('README.md')); fireEvent.contextMenu(mdFile!, { clientX: 100, clientY: 200 }); - expect(screen.getByText('Focus in Graph')).toBeInTheDocument(); + expect(screen.getByText('Document Graph')).toBeInTheDocument(); }); - it('does not show Focus in Graph option for non-markdown files', () => { + it('does not show Document Graph option for non-markdown files', () => { const onFocusFileInGraph = vi.fn(); const { container } = render( ); - // Right-click on non-markdown file - should not show Focus in Graph + // Right-click on non-markdown file - should not show Document Graph const jsonFile = Array.from(container.querySelectorAll('[data-file-index]')) .find(el => el.textContent?.includes('package.json')); fireEvent.contextMenu(jsonFile!, { clientX: 100, clientY: 200 }); - expect(screen.queryByText('Focus in Graph')).not.toBeInTheDocument(); + expect(screen.queryByText('Document Graph')).not.toBeInTheDocument(); }); it('calls onFocusFileInGraph with relative path when clicked', () => { @@ -1395,7 +1396,7 @@ describe('FileExplorerPanel', () => { .find(el => el.textContent?.includes('README.md')); fireEvent.contextMenu(mdFile!, { clientX: 100, clientY: 200 }); - const focusButton = screen.getByText('Focus in Graph'); + const focusButton = screen.getByText('Document Graph'); fireEvent.click(focusButton); expect(onFocusFileInGraph).toHaveBeenCalledWith('README.md'); diff --git a/src/__tests__/renderer/components/FilePreview.test.tsx b/src/__tests__/renderer/components/FilePreview.test.tsx index acfe9da1..a9c7d218 100644 --- a/src/__tests__/renderer/components/FilePreview.test.tsx +++ b/src/__tests__/renderer/components/FilePreview.test.tsx @@ -1,3351 +1,223 @@ -/** - * @file FilePreview.test.tsx - * @description Tests for the FilePreview component - * - * FilePreview is a comprehensive file viewer supporting: - * - Syntax-highlighted code files - * - Rendered/raw markdown with image support - * - Image file display - * - File stats (size, tokens, dates) - * - Search with match navigation - * - Keyboard shortcuts - * - Copy to clipboard (path and content) - */ - -import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; -import { render, screen, fireEvent, waitFor } from '@testing-library/react'; import React from 'react'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { render, screen, fireEvent } from '@testing-library/react'; +import { FilePreview } from '../../../renderer/components/FilePreview'; -// Mock dependencies before importing component +// Mock lucide-react icons +vi.mock('lucide-react', () => ({ + FileCode: () => FileCode, + X: () => X, + Eye: () => Eye, + ChevronUp: () => ChevronUp, + ChevronDown: () => ChevronDown, + ChevronLeft: () => ChevronLeft, + ChevronRight: () => ChevronRight, + Clipboard: () => Clipboard, + Loader2: () => Loader2, + Image: () => Image, + Globe: () => Globe, + Save: () => Save, + Edit: () => Edit, + FolderOpen: () => FolderOpen, + AlertTriangle: () => AlertTriangle, + Share2: () => Share2, + GitGraph: () => GitGraph, +})); + +// Mock react-markdown vi.mock('react-markdown', () => ({ - default: ({ children }: { children: string }) => ( -
{children}
- ), + default: ({ children }: { children: string }) =>
{children}
, })); -vi.mock('remark-gfm', () => ({ - default: () => () => {}, -})); +// Mock remark/rehype plugins +vi.mock('remark-gfm', () => ({ default: () => {} })); +vi.mock('rehype-raw', () => ({ default: () => {} })); +vi.mock('rehype-slug', () => ({ default: () => {} })); +vi.mock('remark-frontmatter', () => ({ default: () => {} })); +// Mock syntax highlighter vi.mock('react-syntax-highlighter', () => ({ - Prism: ({ children, language, showLineNumbers }: any) => ( -
-      {children}
-    
- ), + Prism: ({ children }: { children: string }) =>
{children}
, })); - vi.mock('react-syntax-highlighter/dist/esm/styles/prism', () => ({ vscDarkPlus: {}, })); +// Mock unist-util-visit vi.mock('unist-util-visit', () => ({ visit: vi.fn(), })); -vi.mock('../../../renderer/components/MermaidRenderer', () => ({ - MermaidRenderer: ({ chart }: { chart: string }) => ( -
{chart}
- ), -})); - -vi.mock('../../../renderer/components/ui/Modal', () => ({ - Modal: ({ children, title, footer, onClose }: any) => ( -
-
{children}
-
{footer}
-
- ), - ModalFooter: ({ onCancel, onConfirm, cancelLabel, confirmLabel }: any) => ( - <> - - - - ), -})); - -vi.mock('js-tiktoken', () => ({ - getEncoding: vi.fn(() => ({ - encode: vi.fn((text: string) => new Array(Math.ceil(text.length / 4))), - })), -})); - -vi.mock('../../../renderer/utils/shortcutFormatter', () => ({ - formatShortcutKeys: vi.fn((keys: string[]) => keys.join('+')), -})); - // Mock LayerStackContext -const mockRegisterLayer = vi.fn(() => 'layer-123'); -const mockUnregisterLayer = vi.fn(); -const mockUpdateLayerHandler = vi.fn(); - vi.mock('../../../renderer/contexts/LayerStackContext', () => ({ useLayerStack: () => ({ - registerLayer: mockRegisterLayer, - unregisterLayer: mockUnregisterLayer, - updateLayerHandler: mockUpdateLayerHandler, + registerLayer: vi.fn(() => 'layer-123'), + unregisterLayer: vi.fn(), + updateLayerHandler: vi.fn(), }), })); -// Import component after mocks -import { FilePreview } from '../../../renderer/components/FilePreview'; - -// Helper to create mock theme -const createMockTheme = () => ({ - colors: { - bgMain: '#1e1e1e', - bgSidebar: '#252526', - bgActivity: '#333333', - textMain: '#ffffff', - textDim: '#888888', - accent: '#007acc', - accentForeground: '#ffffff', - border: '#404040', - error: '#f44336', - success: '#4caf50', - warning: '#ff9800', +// Mock MODAL_PRIORITIES +vi.mock('../../../renderer/constants/modalPriorities', () => ({ + MODAL_PRIORITIES: { + FILE_PREVIEW: 100, }, -}); +})); -// Helper to create mock file -const createMockFile = (overrides: Partial<{ name: string; content: string; path: string }> = {}) => ({ - name: 'test.ts', - content: 'const x = 1;', - path: '/project/src/test.ts', - ...overrides, -}); +// Mock MermaidRenderer +vi.mock('../../../renderer/components/MermaidRenderer', () => ({ + MermaidRenderer: () =>
Mermaid
, +})); -// Helper to create mock shortcuts -const createMockShortcuts = () => ({ - copyFilePath: { keys: ['Meta', 'Shift', 'C'] }, - toggleMarkdownMode: { keys: ['Meta', 'M'] }, -}); +// Mock token counter - getEncoder must return a Promise +vi.mock('../../../renderer/utils/tokenCounter', () => ({ + getEncoder: vi.fn(() => Promise.resolve({ encode: () => [1, 2, 3] })), + formatTokenCount: vi.fn((count: number) => `${count} tokens`), +})); + +// Mock shortcut formatter +vi.mock('../../../renderer/utils/shortcutFormatter', () => ({ + formatShortcutKeys: vi.fn((keys: string) => keys), +})); + +// Mock remarkFileLinks +vi.mock('../../../renderer/utils/remarkFileLinks', () => ({ + remarkFileLinks: vi.fn(() => () => {}), +})); + +// Mock remarkFrontmatterTable +vi.mock('../../../renderer/utils/remarkFrontmatterTable', () => ({ + remarkFrontmatterTable: vi.fn(() => () => {}), +})); + +// Mock gitUtils +vi.mock('../../../shared/gitUtils', () => ({ + isImageFile: (filename: string) => /\.(png|jpg|jpeg|gif|webp|svg)$/i.test(filename), +})); + +const mockTheme = { + colors: { + bgMain: '#1a1a2e', + bgActivity: '#16213e', + textMain: '#eee', + textDim: '#888', + border: '#333', + accent: '#4a9eff', + success: '#22c55e', + }, +}; + +const defaultProps = { + file: { name: 'test.md', content: '# Hello World', path: '/test/test.md' }, + onClose: vi.fn(), + theme: mockTheme, + markdownEditMode: false, + setMarkdownEditMode: vi.fn(), + shortcuts: {}, +}; describe('FilePreview', () => { - let mockSetMarkdownRawMode: ReturnType; - beforeEach(() => { vi.clearAllMocks(); - mockSetMarkdownRawMode = vi.fn(); - - // Reset window.maestro mocks - vi.mocked(window.maestro.fs.stat).mockResolvedValue({ - size: 1024, - createdAt: '2024-01-01T00:00:00.000Z', - modifiedAt: '2024-01-15T12:30:00.000Z', - }); - vi.mocked(window.maestro.fs.readFile).mockResolvedValue('data:image/png;base64,abc123'); - - // Mock clipboard - Object.assign(navigator, { - clipboard: { - writeText: vi.fn().mockResolvedValue(undefined), - write: vi.fn().mockResolvedValue(undefined), - }, - }); - - // Mock scrollIntoView - Element.prototype.scrollIntoView = vi.fn(); }); - afterEach(() => { - vi.restoreAllMocks(); - }); - - // ============================================================================= - // NULL FILE HANDLING - // ============================================================================= - - describe('null file handling', () => { - it('returns null when file is null', () => { - const { container } = render( + describe('Document Graph button', () => { + it('shows Document Graph button for markdown files when onOpenInGraph is provided', () => { + const onOpenInGraph = vi.fn(); + render( ); + + const graphButton = screen.getByTitle('View in Document Graph (⌘⇧G)'); + expect(graphButton).toBeInTheDocument(); + expect(screen.getByTestId('gitgraph-icon')).toBeInTheDocument(); + }); + + it('calls onOpenInGraph when Document Graph button is clicked', () => { + const onOpenInGraph = vi.fn(); + render( + + ); + + const graphButton = screen.getByTitle('View in Document Graph (⌘⇧G)'); + fireEvent.click(graphButton); + + expect(onOpenInGraph).toHaveBeenCalledOnce(); + }); + + it('does not show Document Graph button when onOpenInGraph is not provided', () => { + render( + + ); + + expect(screen.queryByTitle('View in Document Graph (⌘⇧G)')).not.toBeInTheDocument(); + }); + + it('does not show Document Graph button for non-markdown files', () => { + const onOpenInGraph = vi.fn(); + render( + + ); + + expect(screen.queryByTitle('View in Document Graph (⌘⇧G)')).not.toBeInTheDocument(); + }); + + it('shows Document Graph button for uppercase .MD extension', () => { + const onOpenInGraph = vi.fn(); + render( + + ); + + expect(screen.getByTitle('View in Document Graph (⌘⇧G)')).toBeInTheDocument(); + }); + }); + + describe('basic rendering', () => { + it('renders file preview with file name', () => { + render(); + + expect(screen.getByText('test.md')).toBeInTheDocument(); + }); + + it('renders close button', () => { + render(); + + expect(screen.getByTestId('x-icon')).toBeInTheDocument(); + }); + + it('calls onClose when close button is clicked', () => { + const onClose = vi.fn(); + render(); + + const closeButton = screen.getByTestId('x-icon').parentElement; + fireEvent.click(closeButton!); + + expect(onClose).toHaveBeenCalledOnce(); + }); + + it('renders nothing when file is null', () => { + const { container } = render(); + expect(container.firstChild).toBeNull(); }); }); - - // ============================================================================= - // BASIC RENDERING - // ============================================================================= - - describe('basic rendering', () => { - it('renders with a TypeScript file', () => { - render( - - ); - - expect(screen.getByText('test.ts')).toBeInTheDocument(); - expect(screen.getByText('/project/src')).toBeInTheDocument(); - }); - - it('renders file content in syntax highlighter for code files', () => { - render( - - ); - - const highlighter = screen.getByTestId('syntax-highlighter'); - expect(highlighter).toBeInTheDocument(); - expect(highlighter).toHaveAttribute('data-language', 'typescript'); - }); - - it('applies theme colors to container', () => { - render( - - ); - - const container = screen.getByText('test.ts').closest('[tabindex="0"]'); - expect(container).toHaveStyle({ backgroundColor: '#1e1e1e' }); - }); - - it('registers layer on mount', () => { - render( - - ); - - expect(mockRegisterLayer).toHaveBeenCalledWith( - expect.objectContaining({ - type: 'overlay', - blocksLowerLayers: true, - capturesFocus: true, - ariaLabel: 'File Preview', - }) - ); - }); - - it('unregisters layer on unmount', () => { - const { unmount } = render( - - ); - - unmount(); - expect(mockUnregisterLayer).toHaveBeenCalledWith('layer-123'); - }); - }); - - // ============================================================================= - // FILE STATS - // ============================================================================= - - describe('file stats', () => { - it('displays file size when stats are available', async () => { - render( - - ); - - await waitFor(() => { - expect(screen.getByText('1 KB')).toBeInTheDocument(); - }); - }); - - it('displays token count', async () => { - render( - - ); - - await waitFor(() => { - expect(screen.getByText('Tokens:')).toBeInTheDocument(); - }); - }); - - it('displays modified and created dates', async () => { - render( - - ); - - await waitFor(() => { - expect(screen.getByText('Modified:')).toBeInTheDocument(); - expect(screen.getByText('Created:')).toBeInTheDocument(); - }); - }); - - it('handles file stats error gracefully', async () => { - vi.mocked(window.maestro.fs.stat).mockRejectedValue(new Error('File not found')); - - render( - - ); - - expect(screen.getByText('test.ts')).toBeInTheDocument(); - }); - }); - - // ============================================================================= - // COPY FUNCTIONALITY - // ============================================================================= - - describe('copy functionality', () => { - it('copies file path to clipboard', async () => { - render( - - ); - - const copyPathBtn = screen.getByTitle('Copy full path to clipboard'); - fireEvent.click(copyPathBtn); - - await waitFor(() => { - expect(navigator.clipboard.writeText).toHaveBeenCalledWith('/project/src/test.ts'); - }); - }); - - it('shows copy notification after copying path', async () => { - render( - - ); - - const copyPathBtn = screen.getByTitle('Copy full path to clipboard'); - fireEvent.click(copyPathBtn); - - await waitFor(() => { - expect(screen.getByText('File Path Copied to Clipboard')).toBeInTheDocument(); - }); - }); - - it('copies content to clipboard for text files', async () => { - render( - - ); - - const copyContentBtn = screen.getByTitle('Copy content to clipboard'); - fireEvent.click(copyContentBtn); - - await waitFor(() => { - expect(navigator.clipboard.writeText).toHaveBeenCalledWith('const x = 42;'); - }); - }); - - it('shows content copy notification', async () => { - render( - - ); - - const copyContentBtn = screen.getByTitle('Copy content to clipboard'); - fireEvent.click(copyContentBtn); - - await waitFor(() => { - expect(screen.getByText('Content Copied to Clipboard')).toBeInTheDocument(); - }); - }); - }); - - // ============================================================================= - // CLOSE BUTTON - // ============================================================================= - - describe('close button', () => { - it('calls onClose when close button is clicked', async () => { - const onClose = vi.fn(); - - render( - - ); - - const closeIcon = screen.getAllByTestId('x-icon')[0]; - const closeBtn = closeIcon.closest('button'); - expect(closeBtn).toBeTruthy(); - fireEvent.click(closeBtn!); - - expect(onClose).toHaveBeenCalled(); - }); - }); - - // ============================================================================= - // MARKDOWN FILES - // ============================================================================= - - describe('markdown files', () => { - const markdownFile = createMockFile({ - name: 'README.md', - content: '# Hello World\n\nThis is a test.', - path: '/project/README.md', - }); - - it('shows markdown toggle button for .md files', () => { - render( - - ); - - const toggleBtn = screen.getByTitle(/Edit file|Show preview/); - expect(toggleBtn).toBeInTheDocument(); - }); - - it('renders markdown content when not in raw mode', () => { - render( - - ); - - expect(screen.getByTestId('react-markdown')).toBeInTheDocument(); - }); - - it('renders textarea editor when markdownEditMode is true', () => { - render( - - ); - - expect(screen.queryByTestId('react-markdown')).not.toBeInTheDocument(); - // In edit mode, we show a textarea instead of raw text - const textarea = screen.getByRole('textbox'); - expect(textarea).toBeInTheDocument(); - expect(textarea).toHaveValue('# Hello World\n\nThis is a test.'); - }); - - it('toggles markdown mode when button is clicked', async () => { - render( - - ); - - const toggleBtn = screen.getByTitle(/Edit file/); - fireEvent.click(toggleBtn); - - expect(mockSetMarkdownRawMode).toHaveBeenCalledWith(true); - }); - - it('does not show markdown toggle for non-markdown files', () => { - render( - - ); - - expect(screen.queryByTitle(/Edit file/)).not.toBeInTheDocument(); - expect(screen.queryByTitle(/Show rendered markdown/)).not.toBeInTheDocument(); - }); - }); - - // ============================================================================= - // IMAGE FILES - // ============================================================================= - - describe('image files', () => { - const imageFile = createMockFile({ - name: 'logo.png', - content: 'data:image/png;base64,abc123', - path: '/project/assets/logo.png', - }); - - it('renders image tag for image files', () => { - render( - - ); - - const img = screen.getByRole('img'); - expect(img).toBeInTheDocument(); - expect(img).toHaveAttribute('src', 'data:image/png;base64,abc123'); - }); - - it('does not show syntax highlighter for images', () => { - render( - - ); - - expect(screen.queryByTestId('syntax-highlighter')).not.toBeInTheDocument(); - }); - - it('shows copy image button for image files', () => { - render( - - ); - - expect(screen.getByTitle('Copy image to clipboard')).toBeInTheDocument(); - }); - - it('recognizes various image extensions', () => { - const extensions = ['png', 'jpg', 'jpeg', 'gif', 'bmp', 'webp', 'svg', 'ico']; - - extensions.forEach(ext => { - const { unmount } = render( - - ); - - expect(screen.getByRole('img')).toBeInTheDocument(); - unmount(); - }); - }); - }); - - // ============================================================================= - // LANGUAGE DETECTION - // ============================================================================= - - describe('language detection', () => { - const testCases = [ - { ext: 'ts', lang: 'typescript' }, - { ext: 'tsx', lang: 'tsx' }, - { ext: 'js', lang: 'javascript' }, - { ext: 'jsx', lang: 'jsx' }, - { ext: 'json', lang: 'json' }, - { ext: 'py', lang: 'python' }, - { ext: 'rb', lang: 'ruby' }, - { ext: 'go', lang: 'go' }, - { ext: 'rs', lang: 'rust' }, - { ext: 'java', lang: 'java' }, - { ext: 'c', lang: 'c' }, - { ext: 'cpp', lang: 'cpp' }, - { ext: 'cs', lang: 'csharp' }, - { ext: 'php', lang: 'php' }, - { ext: 'html', lang: 'html' }, - { ext: 'css', lang: 'css' }, - { ext: 'scss', lang: 'scss' }, - { ext: 'sql', lang: 'sql' }, - { ext: 'sh', lang: 'bash' }, - { ext: 'yaml', lang: 'yaml' }, - { ext: 'yml', lang: 'yaml' }, - { ext: 'toml', lang: 'toml' }, - { ext: 'xml', lang: 'xml' }, - ]; - - testCases.forEach(({ ext, lang }) => { - it(`detects ${lang} for .${ext} files`, () => { - render( - - ); - - const highlighter = screen.getByTestId('syntax-highlighter'); - expect(highlighter).toHaveAttribute('data-language', lang); - }); - }); - - it('defaults to text for unknown extensions', () => { - render( - - ); - - const highlighter = screen.getByTestId('syntax-highlighter'); - expect(highlighter).toHaveAttribute('data-language', 'text'); - }); - }); - - // ============================================================================= - // SEARCH FUNCTIONALITY - // ============================================================================= - - describe('search functionality', () => { - it('opens search with Cmd+F', async () => { - render( - - ); - - const container = screen.getByText('test.ts').closest('[tabindex="0"]'); - fireEvent.keyDown(container!, { key: 'f', metaKey: true }); - - await waitFor(() => { - expect(screen.getByPlaceholderText(/Search in file/)).toBeInTheDocument(); - }); - }); - - it('focuses search input when opened', async () => { - render( - - ); - - const container = screen.getByText('test.ts').closest('[tabindex="0"]'); - fireEvent.keyDown(container!, { key: 'f', metaKey: true }); - - await waitFor(() => { - const searchInput = screen.getByPlaceholderText(/Search in file/); - expect(searchInput).toBeInTheDocument(); - }); - }); - - it('closes search with Escape', async () => { - render( - - ); - - // Open search - const container = screen.getByText('test.ts').closest('[tabindex="0"]'); - fireEvent.keyDown(container!, { key: 'f', metaKey: true }); - - await waitFor(() => { - expect(screen.getByPlaceholderText(/Search in file/)).toBeInTheDocument(); - }); - - // Close with Escape - const searchInput = screen.getByPlaceholderText(/Search in file/); - fireEvent.keyDown(searchInput, { key: 'Escape' }); - - await waitFor(() => { - expect(screen.queryByPlaceholderText(/Search in file/)).not.toBeInTheDocument(); - }); - }); - - it('shows no matches message when no results', async () => { - render( - - ); - - // Open search - const container = screen.getByText('test.ts').closest('[tabindex="0"]'); - fireEvent.keyDown(container!, { key: 'f', metaKey: true }); - - await waitFor(() => { - expect(screen.getByPlaceholderText(/Search in file/)).toBeInTheDocument(); - }); - - // Type a search query that won't match - const searchInput = screen.getByPlaceholderText(/Search in file/); - fireEvent.change(searchInput, { target: { value: 'xyz123notfound' } }); - - await waitFor(() => { - expect(screen.getByText('No matches')).toBeInTheDocument(); - }); - }); - - it('displays match count when matches found', async () => { - render( - - ); - - // Open search - const container = screen.getByText('test.md').closest('[tabindex="0"]'); - fireEvent.keyDown(container!, { key: 'f', metaKey: true }); - - await waitFor(() => { - expect(screen.getByPlaceholderText(/Search in file/)).toBeInTheDocument(); - }); - - // Type a search query - const searchInput = screen.getByPlaceholderText(/Search in file/); - fireEvent.change(searchInput, { target: { value: 'test' } }); - - await waitFor(() => { - expect(screen.getByText(/1\/3|2\/3|3\/3/)).toBeInTheDocument(); - }); - }); - - it('shows navigation buttons when search has results', async () => { - render( - - ); - - // Open search - const container = screen.getByText('test.md').closest('[tabindex="0"]'); - fireEvent.keyDown(container!, { key: 'f', metaKey: true }); - - await waitFor(() => { - expect(screen.getByPlaceholderText(/Search in file/)).toBeInTheDocument(); - }); - - // Type a search query - const searchInput = screen.getByPlaceholderText(/Search in file/); - fireEvent.change(searchInput, { target: { value: 'test' } }); - - await waitFor(() => { - expect(screen.getByTitle(/Previous match/)).toBeInTheDocument(); - expect(screen.getByTitle(/Next match/)).toBeInTheDocument(); - }); - }); - }); - - // ============================================================================= - // KEYBOARD NAVIGATION - // ============================================================================= - - describe('keyboard navigation', () => { - it('scrolls up with ArrowUp', () => { - render( - - ); - - const container = screen.getByText('test.ts').closest('[tabindex="0"]'); - fireEvent.keyDown(container!, { key: 'ArrowUp' }); - - expect(container).toBeInTheDocument(); - }); - - it('scrolls down with ArrowDown', () => { - render( - - ); - - const container = screen.getByText('test.ts').closest('[tabindex="0"]'); - fireEvent.keyDown(container!, { key: 'ArrowDown' }); - - expect(container).toBeInTheDocument(); - }); - - it('does not open search with Cmd+/', () => { - render( - - ); - - const container = screen.getByText('test.ts').closest('[tabindex="0"]'); - fireEvent.keyDown(container!, { key: '/', metaKey: true }); - - expect(screen.queryByPlaceholderText(/Search in file/)).not.toBeInTheDocument(); - }); - - it('triggers copy path with keyboard shortcut', () => { - render( - - ); - - const container = screen.getByText('test.ts').closest('[tabindex="0"]'); - fireEvent.keyDown(container!, { key: 'c', metaKey: true, shiftKey: true }); - - expect(navigator.clipboard.writeText).toHaveBeenCalledWith('/project/src/test.ts'); - }); - - it('toggles markdown mode with keyboard shortcut', async () => { - render( - - ); - - const container = screen.getByText('README.md').closest('[tabindex="0"]'); - fireEvent.keyDown(container!, { key: 'm', metaKey: true }); - - expect(mockSetMarkdownRawMode).toHaveBeenCalledWith(true); - }); - }); - - // ============================================================================= - // LAYER ESCAPE HANDLING - // ============================================================================= - - describe('layer escape handling', () => { - it('passes escape handler to layer registration', () => { - const onClose = vi.fn(); - - render( - - ); - - expect(mockRegisterLayer).toHaveBeenCalledWith( - expect.objectContaining({ - onEscape: expect.any(Function), - }) - ); - }); - - it('updates layer handler when search opens', async () => { - render( - - ); - - // Open search - const container = screen.getByText('test.ts').closest('[tabindex="0"]'); - fireEvent.keyDown(container!, { key: 'f', metaKey: true }); - - await waitFor(() => { - expect(mockUpdateLayerHandler).toHaveBeenCalled(); - }); - }); - }); - - // ============================================================================= - // FILE SIZE FORMATTING - // ============================================================================= - - describe('file size formatting', () => { - it('formats bytes correctly', async () => { - vi.mocked(window.maestro.fs.stat).mockResolvedValue({ - size: 512, - createdAt: '2024-01-01T00:00:00.000Z', - modifiedAt: '2024-01-01T00:00:00.000Z', - }); - - render( - - ); - - await waitFor(() => { - expect(screen.getByText('512 B')).toBeInTheDocument(); - }); - }); - - it('formats kilobytes correctly', async () => { - vi.mocked(window.maestro.fs.stat).mockResolvedValue({ - size: 2048, - createdAt: '2024-01-01T00:00:00.000Z', - modifiedAt: '2024-01-01T00:00:00.000Z', - }); - - render( - - ); - - await waitFor(() => { - expect(screen.getByText('2 KB')).toBeInTheDocument(); - }); - }); - - it('formats megabytes correctly', async () => { - vi.mocked(window.maestro.fs.stat).mockResolvedValue({ - size: 1048576 * 5.5, // 5.5 MB - createdAt: '2024-01-01T00:00:00.000Z', - modifiedAt: '2024-01-01T00:00:00.000Z', - }); - - render( - - ); - - await waitFor(() => { - expect(screen.getByText('5.5 MB')).toBeInTheDocument(); - }); - }); - - it('formats zero bytes correctly', async () => { - vi.mocked(window.maestro.fs.stat).mockResolvedValue({ - size: 0, - createdAt: '2024-01-01T00:00:00.000Z', - modifiedAt: '2024-01-01T00:00:00.000Z', - }); - - render( - - ); - - await waitFor(() => { - expect(screen.getByText('0 B')).toBeInTheDocument(); - }); - }); - }); - - // ============================================================================= - // TOKEN COUNT FORMATTING - // ============================================================================= - - describe('token count formatting', () => { - it('displays token count for text files', async () => { - // The mock at the top generates tokens based on content.length / 4 - render( - - ); - - // Should show Tokens label with some count - await waitFor(() => { - expect(screen.getByText('Tokens:')).toBeInTheDocument(); - }); - }); - - it('shows token count section in stats bar', async () => { - render( - - ); - - await waitFor(() => { - expect(screen.getByText('Tokens:')).toBeInTheDocument(); - }); - }); - - it('does not show tokens section for image files', async () => { - render( - - ); - - await waitFor(() => { - expect(screen.getByText('Size:')).toBeInTheDocument(); - }); - - // Token count should not appear for images - expect(screen.queryByText('Tokens:')).not.toBeInTheDocument(); - }); - }); - - // ============================================================================= - // DIRECTORY PATH DISPLAY - // ============================================================================= - - describe('directory path display', () => { - it('displays directory without filename', () => { - render( - - ); - - expect(screen.getByText('index.ts')).toBeInTheDocument(); - expect(screen.getByText('/Users/dev/project/src/components')).toBeInTheDocument(); - }); - - it('handles root-level files', () => { - render( - - ); - - expect(screen.getByText('package.json')).toBeInTheDocument(); - }); - }); - - // ============================================================================= - // STATS BAR VISIBILITY - // ============================================================================= - - describe('stats bar visibility', () => { - it('shows stats bar initially', async () => { - render( - - ); - - await waitFor(() => { - expect(screen.getByText('Size:')).toBeInTheDocument(); - }); - }); - }); - - // ============================================================================= - // EDGE CASES - // ============================================================================= - - describe('edge cases', () => { - it('handles files with no extension', () => { - render( - - ); - - const highlighter = screen.getByTestId('syntax-highlighter'); - expect(highlighter).toHaveAttribute('data-language', 'text'); - }); - - it('handles empty file content', () => { - render( - - ); - - expect(screen.getByText('test.ts')).toBeInTheDocument(); - }); - - it('handles very long filenames', () => { - const longName = 'a'.repeat(200) + '.ts'; - - render( - - ); - - expect(screen.getByText(longName)).toBeInTheDocument(); - }); - - it('handles files with special characters in name', () => { - render( - - ); - - expect(screen.getByText('test file (1).ts')).toBeInTheDocument(); - }); - - it('handles unicode content', () => { - render( - - ); - - expect(screen.getByText(/Hello 世界/)).toBeInTheDocument(); - }); - - it('handles missing shortcuts gracefully', () => { - render( - - ); - - expect(screen.getByText('test.ts')).toBeInTheDocument(); - }); - }); - - // ============================================================================= - // IMAGE COPY - // ============================================================================= - - describe('image copy', () => { - it('shows notification when copying image', async () => { - // The clipboard API may fall back in test environment - render( - - ); - - const copyBtn = screen.getByTitle('Copy image to clipboard'); - fireEvent.click(copyBtn); - - // Should show some copy notification (either Image or URL copied) - await waitFor(() => { - expect(screen.getByText(/Copied to Clipboard/)).toBeInTheDocument(); - }); - }); - - it('calls fetch when copying image', async () => { - global.fetch = vi.fn().mockResolvedValue({ - blob: () => Promise.resolve(new Blob(['image data'], { type: 'image/png' })), - }); - - render( - - ); - - const copyBtn = screen.getByTitle('Copy image to clipboard'); - fireEvent.click(copyBtn); - - // The component fetches the data URL to create a blob for clipboard - await waitFor(() => { - expect(global.fetch).toHaveBeenCalledWith('data:image/png;base64,abc123'); - }); - }); - - it('falls back to copying data URL if image copy fails', async () => { - global.fetch = vi.fn().mockRejectedValue(new Error('Network error')); - - render( - - ); - - const copyBtn = screen.getByTitle('Copy image to clipboard'); - fireEvent.click(copyBtn); - - await waitFor(() => { - expect(navigator.clipboard.writeText).toHaveBeenCalledWith('data:image/png;base64,abc123'); - }); - }); - }); - - // ============================================================================= - // SEARCH NAVIGATION BUTTONS - // ============================================================================= - - describe('search navigation buttons', () => { - it('previous button navigates to previous match', async () => { - render( - - ); - - // Open search - const container = screen.getByText('test.md').closest('[tabindex="0"]'); - fireEvent.keyDown(container!, { key: 'f', metaKey: true }); - - await waitFor(() => { - expect(screen.getByPlaceholderText(/Search in file/)).toBeInTheDocument(); - }); - - const searchInput = screen.getByPlaceholderText(/Search in file/); - fireEvent.change(searchInput, { target: { value: 'test' } }); - - await waitFor(() => { - expect(screen.getByTitle(/Previous match/)).toBeInTheDocument(); - }); - - const prevBtn = screen.getByTitle(/Previous match/); - fireEvent.click(prevBtn); - - await waitFor(() => { - expect(screen.getByText('3/3')).toBeInTheDocument(); - }); - }); - - it('next button navigates to next match', async () => { - render( - - ); - - // Open search - const container = screen.getByText('test.md').closest('[tabindex="0"]'); - fireEvent.keyDown(container!, { key: 'f', metaKey: true }); - - await waitFor(() => { - expect(screen.getByPlaceholderText(/Search in file/)).toBeInTheDocument(); - }); - - const searchInput = screen.getByPlaceholderText(/Search in file/); - fireEvent.change(searchInput, { target: { value: 'test' } }); - - await waitFor(() => { - expect(screen.getByTitle(/Next match/)).toBeInTheDocument(); - }); - - const nextBtn = screen.getByTitle(/Next match/); - fireEvent.click(nextBtn); - - await waitFor(() => { - expect(screen.getByText('2/3')).toBeInTheDocument(); - }); - }); - }); - - // ============================================================================= - // SEARCH KEYBOARD SHORTCUTS - // ============================================================================= - - describe('search keyboard shortcuts', () => { - it('Enter navigates to next match', async () => { - render( - - ); - - // Open search - const container = screen.getByText('test.md').closest('[tabindex="0"]'); - fireEvent.keyDown(container!, { key: 'f', metaKey: true }); - - await waitFor(() => { - expect(screen.getByPlaceholderText(/Search in file/)).toBeInTheDocument(); - }); - - const searchInput = screen.getByPlaceholderText(/Search in file/); - fireEvent.change(searchInput, { target: { value: 'test' } }); - - await waitFor(() => { - expect(screen.getByText('1/3')).toBeInTheDocument(); - }); - - // Press Enter to go to next - fireEvent.keyDown(searchInput, { key: 'Enter' }); - - await waitFor(() => { - expect(screen.getByText('2/3')).toBeInTheDocument(); - }); - }); - - it('Shift+Enter navigates to previous match', async () => { - render( - - ); - - // Open search - const container = screen.getByText('test.md').closest('[tabindex="0"]'); - fireEvent.keyDown(container!, { key: 'f', metaKey: true }); - - await waitFor(() => { - expect(screen.getByPlaceholderText(/Search in file/)).toBeInTheDocument(); - }); - - const searchInput = screen.getByPlaceholderText(/Search in file/); - fireEvent.change(searchInput, { target: { value: 'test' } }); - - await waitFor(() => { - expect(screen.getByText('1/3')).toBeInTheDocument(); - }); - - // Press Shift+Enter to go to previous (wraps to last) - fireEvent.keyDown(searchInput, { key: 'Enter', shiftKey: true }); - - await waitFor(() => { - expect(screen.getByText('3/3')).toBeInTheDocument(); - }); - }); - }); - - // ============================================================================= - // MODIFIER KEY SCROLLING - // ============================================================================= - - describe('modifier key scrolling', () => { - it('Cmd+ArrowUp jumps to top', () => { - render( - - ); - - const container = screen.getByText('test.ts').closest('[tabindex="0"]'); - fireEvent.keyDown(container!, { key: 'ArrowUp', metaKey: true }); - - expect(container).toBeInTheDocument(); - }); - - it('Cmd+ArrowDown jumps to bottom', () => { - render( - - ); - - const container = screen.getByText('test.ts').closest('[tabindex="0"]'); - fireEvent.keyDown(container!, { key: 'ArrowDown', metaKey: true }); - - expect(container).toBeInTheDocument(); - }); - - it('Alt+ArrowUp pages up', () => { - render( - - ); - - const container = screen.getByText('test.ts').closest('[tabindex="0"]'); - fireEvent.keyDown(container!, { key: 'ArrowUp', altKey: true }); - - expect(container).toBeInTheDocument(); - }); - - it('Alt+ArrowDown pages down', () => { - render( - - ); - - const container = screen.getByText('test.ts').closest('[tabindex="0"]'); - fireEvent.keyDown(container!, { key: 'ArrowDown', altKey: true }); - - expect(container).toBeInTheDocument(); - }); - - it('Ctrl+ArrowUp also jumps to top', () => { - render( - - ); - - const container = screen.getByText('test.ts').closest('[tabindex="0"]'); - fireEvent.keyDown(container!, { key: 'ArrowUp', ctrlKey: true }); - - expect(container).toBeInTheDocument(); - }); - }); - - // ============================================================================= - // LINE NUMBERS - // ============================================================================= - - describe('line numbers', () => { - it('shows line numbers in syntax highlighter', () => { - render( - - ); - - const highlighter = screen.getByTestId('syntax-highlighter'); - expect(highlighter).toHaveAttribute('data-line-numbers', 'true'); - }); - }); -}); - -// ============================================================================= -// UTILITY FUNCTION UNIT TESTS -// ============================================================================= - -describe('FilePreview utility functions', () => { - describe('getLanguageFromFilename coverage', () => { - const testFiles = [ - { file: 'test.ts', expected: 'typescript' }, - { file: 'test.tsx', expected: 'tsx' }, - { file: 'test.js', expected: 'javascript' }, - { file: 'test.jsx', expected: 'jsx' }, - { file: 'test.json', expected: 'json' }, - { file: 'test.py', expected: 'python' }, - { file: 'test.rb', expected: 'ruby' }, - { file: 'test.go', expected: 'go' }, - { file: 'test.rs', expected: 'rust' }, - { file: 'test.java', expected: 'java' }, - { file: 'test.c', expected: 'c' }, - { file: 'test.cpp', expected: 'cpp' }, - { file: 'test.cs', expected: 'csharp' }, - { file: 'test.php', expected: 'php' }, - { file: 'test.html', expected: 'html' }, - { file: 'test.css', expected: 'css' }, - { file: 'test.scss', expected: 'scss' }, - { file: 'test.sql', expected: 'sql' }, - { file: 'test.sh', expected: 'bash' }, - { file: 'test.yaml', expected: 'yaml' }, - { file: 'test.yml', expected: 'yaml' }, - { file: 'test.toml', expected: 'toml' }, - { file: 'test.xml', expected: 'xml' }, - { file: 'test.unknown', expected: 'text' }, - { file: 'noextension', expected: 'text' }, - ]; - - testFiles.forEach(({ file, expected }) => { - it(`correctly identifies ${file} as ${expected}`, () => { - render( - - ); - - if (file.endsWith('.md')) { - expect(screen.getByTestId('react-markdown')).toBeInTheDocument(); - } else if (!['png', 'jpg', 'jpeg', 'gif', 'bmp', 'webp', 'svg', 'ico'].some(ext => file.endsWith(`.${ext}`))) { - const highlighter = screen.getByTestId('syntax-highlighter'); - expect(highlighter).toHaveAttribute('data-language', expected); - } - }); - }); - }); - - describe('isImageFile coverage', () => { - const imageExtensions = ['png', 'jpg', 'jpeg', 'gif', 'bmp', 'webp', 'svg', 'ico']; - const nonImageExtensions = ['ts', 'js', 'txt', 'html']; - - imageExtensions.forEach(ext => { - it(`recognizes .${ext} as image file`, () => { - render( - - ); - - expect(screen.getByRole('img')).toBeInTheDocument(); - }); - }); - - nonImageExtensions.forEach(ext => { - it(`does not recognize .${ext} as image file`, () => { - render( - - ); - - expect(screen.queryByRole('img')).not.toBeInTheDocument(); - }); - }); - }); -}); - -// ============================================================================= -// MARKDOWN IMAGE COMPONENT TESTS -// ============================================================================= - -describe('MarkdownImage component', () => { - // Note: ReactMarkdown is mocked with a simple component that doesn't render custom img tags - // These tests verify that markdown files with image syntax render without errors - describe('markdown files with images', () => { - it('renders markdown file containing image syntax', async () => { - render( - - ); - - // The mocked ReactMarkdown renders the content as plain text - await waitFor(() => { - expect(screen.getByTestId('react-markdown')).toBeInTheDocument(); - }); - }); - - it('renders markdown file with data URL image', async () => { - render( - - ); - - await waitFor(() => { - expect(screen.getByTestId('react-markdown')).toBeInTheDocument(); - }); - }); - - it('renders markdown file with HTTP URL image', async () => { - render( - - ); - - await waitFor(() => { - expect(screen.getByTestId('react-markdown')).toBeInTheDocument(); - }); - }); - }); -}); - -// ============================================================================= -// MARKDOWN LINK INTERACTIONS -// ============================================================================= - -describe('Markdown link interactions', () => { - // Note: ReactMarkdown is mocked, so we can't test actual link interactions - // The tests verify that markdown files render without errors - it('renders markdown files with link syntax', async () => { - render( - - ); - - // ReactMarkdown is mocked, verify content renders - await waitFor(() => { - expect(screen.getByTestId('react-markdown')).toBeInTheDocument(); - }); - }); -}); - -// ============================================================================= -// STATS BAR SCROLL BEHAVIOR -// ============================================================================= - -describe('Stats bar scroll behavior', () => { - it('hides stats bar when scrolled down', async () => { - render( - - ); - - // Wait for stats to load - await waitFor(() => { - expect(screen.getByText('Size:')).toBeInTheDocument(); - }); - - // Find the content scroll container and simulate scrolling - const container = screen.getByText('test.ts').closest('[tabindex="0"]'); - const scrollContainer = container?.querySelector('.overflow-y-auto'); - expect(scrollContainer).toBeTruthy(); - - // Simulate scroll down - if (scrollContainer) { - Object.defineProperty(scrollContainer, 'scrollTop', { value: 100, writable: true }); - fireEvent.scroll(scrollContainer); - } - - // The stats bar should be hidden (note: component uses showStatsBar state) - // We can't easily test DOM hiding without proper scroll simulation - // but we verify the component doesn't crash and still renders - expect(screen.getByText('test.ts')).toBeInTheDocument(); - }); -}); - -// ============================================================================= -// HIGHLIGHT SYNTAX (==text==) IN MARKDOWN -// ============================================================================= - -describe('Markdown highlight syntax', () => { - it('renders highlighted text with ==syntax==', async () => { - render( - - ); - - // The component uses ReactMarkdown which is mocked - // The actual highlight processing is in remarkHighlight plugin - // We verify the content renders without errors - await waitFor(() => { - expect(screen.getByTestId('react-markdown')).toBeInTheDocument(); - }); - }); -}); - -// ============================================================================= -// MARKDOWN CODE BLOCKS WITH LANGUAGE -// ============================================================================= - -describe('Markdown code blocks', () => { - it('renders inline code differently from block code', async () => { - render( - - ); - - // ReactMarkdown is mocked, so we just verify it renders - await waitFor(() => { - expect(screen.getByTestId('react-markdown')).toBeInTheDocument(); - }); - }); - - it('renders mermaid code blocks with MermaidRenderer', async () => { - render( - B\n```', - path: '/project/test.md', - })} - onClose={vi.fn()} - theme={createMockTheme()} - markdownEditMode={false} - setMarkdownEditMode={vi.fn()} - shortcuts={createMockShortcuts()} - /> - ); - - // MermaidRenderer is mocked - await waitFor(() => { - expect(screen.getByTestId('react-markdown')).toBeInTheDocument(); - }); - }); -}); - -// ============================================================================= -// FORMAT TOKEN COUNT UTILITY -// ============================================================================= - -describe('Token count formatting', () => { - // Note: js-tiktoken is mocked at the top with a fixed implementation - // These tests verify the component handles token counting without crashing - it('displays tokens section for text files', async () => { - render( - - ); - - // Should show Tokens: label with the token count - await waitFor(() => { - expect(screen.getByText('Tokens:')).toBeInTheDocument(); - }); - }); -}); - -// ============================================================================= -// SEARCH IN MARKDOWN RAW MODE -// ============================================================================= - -describe('Search in markdown with highlighting', () => { - it('highlights matches in raw markdown mode', async () => { - render( - - ); - - // Open search - const container = screen.getByText('test.md').closest('[tabindex="0"]'); - fireEvent.keyDown(container!, { key: 'f', metaKey: true }); - - await waitFor(() => { - expect(screen.getByPlaceholderText(/Search in file/)).toBeInTheDocument(); - }); - - // Type search - const searchInput = screen.getByPlaceholderText(/Search in file/); - fireEvent.change(searchInput, { target: { value: 'test' } }); - - // Verify match count - await waitFor(() => { - expect(screen.getByText('1/3')).toBeInTheDocument(); - }); - }); - - it('keeps rendered markdown when searching in preview mode', async () => { - render( - - ); - - // Verify we start in preview mode (ReactMarkdown is rendered) - expect(screen.getByTestId('react-markdown')).toBeInTheDocument(); - - // Open search - const container = screen.getByText('test.md').closest('[tabindex="0"]'); - fireEvent.keyDown(container!, { key: 'f', metaKey: true }); - - await waitFor(() => { - expect(screen.getByPlaceholderText(/Search in file/)).toBeInTheDocument(); - }); - - // Type search - const searchInput = screen.getByPlaceholderText(/Search in file/); - fireEvent.change(searchInput, { target: { value: 'test' } }); - - // Verify we stay in preview mode (ReactMarkdown is still rendered) - // The search highlights are applied via DOM manipulation, not by switching to raw mode - await waitFor(() => { - expect(screen.getByTestId('react-markdown')).toBeInTheDocument(); - }); - }); -}); - -// ============================================================================= -// GIGABYTE FILE SIZE FORMATTING -// ============================================================================= - -describe('Gigabyte file size formatting', () => { - it('formats gigabytes correctly', async () => { - vi.mocked(window.maestro.fs.stat).mockResolvedValue({ - size: 1073741824 * 2.3, // 2.3 GB - createdAt: '2024-01-01T00:00:00.000Z', - modifiedAt: '2024-01-01T00:00:00.000Z', - }); - - render( - - ); - - await waitFor(() => { - expect(screen.getByText('2.3 GB')).toBeInTheDocument(); - }); - }); -}); - -// ============================================================================= -// UNSAVED CHANGES CONFIRMATION MODAL -// ============================================================================= - -describe('unsaved changes confirmation modal', () => { - const markdownFile = { - name: 'test.md', - content: '# Original Content', - path: '/project/test.md', - }; - - it('shows confirmation modal when pressing Escape with unsaved changes', async () => { - render( - - ); - - // Modify the content in the textarea - const textarea = screen.getByRole('textbox'); - fireEvent.change(textarea, { target: { value: '# Modified Content' } }); - - // The layer's onEscape handler should show the modal when there are changes - // We need to simulate the escape handler being called - // The mockRegisterLayer captures the onEscape callback - const registerCall = mockRegisterLayer.mock.calls[mockRegisterLayer.mock.calls.length - 1]; - const layerConfig = registerCall[0]; - - // Call the onEscape handler - layerConfig.onEscape(); - - await waitFor(() => { - expect(screen.getByTestId('modal')).toBeInTheDocument(); - expect(screen.getByText(/unsaved changes/i)).toBeInTheDocument(); - }); - }); - - it('closes without modal when no changes have been made', async () => { - const onClose = vi.fn(); - - render( - - ); - - // Don't modify the content - just get the escape handler - const registerCall = mockRegisterLayer.mock.calls[mockRegisterLayer.mock.calls.length - 1]; - const layerConfig = registerCall[0]; - - // Call the onEscape handler - should close directly since no changes - layerConfig.onEscape(); - - // Should not show modal - expect(screen.queryByTestId('modal')).not.toBeInTheDocument(); - expect(onClose).toHaveBeenCalled(); - }); - - it('stays open when clicking "No, Stay" in confirmation modal', async () => { - const onClose = vi.fn(); - - render( - - ); - - // Modify the content - const textarea = screen.getByRole('textbox'); - fireEvent.change(textarea, { target: { value: '# Modified Content' } }); - - // Get and call the escape handler - const registerCall = mockRegisterLayer.mock.calls[mockRegisterLayer.mock.calls.length - 1]; - const layerConfig = registerCall[0]; - layerConfig.onEscape(); - - await waitFor(() => { - expect(screen.getByTestId('modal')).toBeInTheDocument(); - }); - - // Click "No, Stay" - const cancelButton = screen.getByTestId('modal-cancel'); - fireEvent.click(cancelButton); - - await waitFor(() => { - expect(screen.queryByTestId('modal')).not.toBeInTheDocument(); - }); - - // onClose should NOT have been called - expect(onClose).not.toHaveBeenCalled(); - }); - - it('closes when clicking "Yes, Discard" in confirmation modal', async () => { - const onClose = vi.fn(); - - render( - - ); - - // Modify the content - const textarea = screen.getByRole('textbox'); - fireEvent.change(textarea, { target: { value: '# Modified Content' } }); - - // Get and call the escape handler - const registerCall = mockRegisterLayer.mock.calls[mockRegisterLayer.mock.calls.length - 1]; - const layerConfig = registerCall[0]; - layerConfig.onEscape(); - - await waitFor(() => { - expect(screen.getByTestId('modal')).toBeInTheDocument(); - }); - - // Click "Yes, Discard" - const confirmButton = screen.getByTestId('modal-confirm'); - fireEvent.click(confirmButton); - - await waitFor(() => { - expect(onClose).toHaveBeenCalled(); - }); - }); - - it('does not show modal in preview mode (not edit mode)', async () => { - const onClose = vi.fn(); - - render( - - ); - - // Get and call the escape handler - const registerCall = mockRegisterLayer.mock.calls[mockRegisterLayer.mock.calls.length - 1]; - const layerConfig = registerCall[0]; - layerConfig.onEscape(); - - // Should close directly without modal - expect(screen.queryByTestId('modal')).not.toBeInTheDocument(); - expect(onClose).toHaveBeenCalled(); - }); -}); - -// ============================================================================= -// NAVIGATION DISABLED IN EDIT MODE -// ============================================================================= - -describe('navigation disabled in edit mode', () => { - const markdownFile = { - name: 'test.md', - content: '# Test', - path: '/project/test.md', - }; - - it('hides navigation buttons in edit mode', () => { - render( - - ); - - // Navigation buttons should be hidden in edit mode - expect(screen.queryByTitle('Go back (⌘←)')).not.toBeInTheDocument(); - expect(screen.queryByTitle('Go forward (⌘→)')).not.toBeInTheDocument(); - }); - - it('shows navigation buttons in preview mode', () => { - render( - - ); - - // Navigation buttons should be visible in preview mode - expect(screen.getByTitle('Go back (⌘←)')).toBeInTheDocument(); - expect(screen.getByTitle('Go forward (⌘→)')).toBeInTheDocument(); - }); - - it('does not navigate with Cmd+Left in edit mode', () => { - const onNavigateBack = vi.fn(); - - render( - - ); - - const container = screen.getByText('test.md').closest('[tabindex="0"]'); - fireEvent.keyDown(container!, { key: 'ArrowLeft', metaKey: true }); - - expect(onNavigateBack).not.toHaveBeenCalled(); - }); - - it('does not navigate with Cmd+Right in edit mode', () => { - const onNavigateForward = vi.fn(); - - render( - - ); - - const container = screen.getByText('test.md').closest('[tabindex="0"]'); - fireEvent.keyDown(container!, { key: 'ArrowRight', metaKey: true }); - - expect(onNavigateForward).not.toHaveBeenCalled(); - }); - - it('navigates with Cmd+Left in preview mode', () => { - const onNavigateBack = vi.fn(); - - render( - - ); - - const container = screen.getByText('test.md').closest('[tabindex="0"]'); - fireEvent.keyDown(container!, { key: 'ArrowLeft', metaKey: true }); - - expect(onNavigateBack).toHaveBeenCalled(); - }); - - it('navigates with Cmd+Right in preview mode', () => { - const onNavigateForward = vi.fn(); - - render( - - ); - - const container = screen.getByText('test.md').closest('[tabindex="0"]'); - fireEvent.keyDown(container!, { key: 'ArrowRight', metaKey: true }); - - expect(onNavigateForward).toHaveBeenCalled(); - }); -}); - -// ============================================================================= -// FILE PREVIEW NAVIGATION - COMPREHENSIVE TESTS FOR REGRESSION CHECKLIST -// ============================================================================= - -describe('file preview navigation - back/forward buttons', () => { - const testFile = { - name: 'current.ts', - content: 'const x = 1;', - path: '/project/current.ts', - }; - - it('renders back button when canGoBack is true', () => { - render( - - ); - - const backBtn = screen.getByTitle('Go back (⌘←)'); - expect(backBtn).toBeInTheDocument(); - }); - - it('renders forward button when canGoForward is true', () => { - render( - - ); - - const forwardBtn = screen.getByTitle('Go forward (⌘→)'); - expect(forwardBtn).toBeInTheDocument(); - }); - - it('does not render back button when canGoBack is false', () => { - render( - - ); - - expect(screen.queryByTitle('Go back (⌘←)')).not.toBeInTheDocument(); - }); - - it('does not render forward button when canGoForward is false', () => { - render( - - ); - - expect(screen.queryByTitle('Go forward (⌘→)')).not.toBeInTheDocument(); - }); - - it('calls onNavigateBack when back button is clicked', () => { - const onNavigateBack = vi.fn(); - - render( - - ); - - const backBtn = screen.getByTitle('Go back (⌘←)'); - fireEvent.click(backBtn); - - expect(onNavigateBack).toHaveBeenCalled(); - }); - - it('calls onNavigateForward when forward button is clicked', () => { - const onNavigateForward = vi.fn(); - - render( - - ); - - const forwardBtn = screen.getByTitle('Go forward (⌘→)'); - fireEvent.click(forwardBtn); - - expect(onNavigateForward).toHaveBeenCalled(); - }); - - it('does not call onNavigateBack with Cmd+Left when canGoBack is false', () => { - const onNavigateBack = vi.fn(); - - render( - - ); - - const container = screen.getByText('current.ts').closest('[tabindex="0"]'); - fireEvent.keyDown(container!, { key: 'ArrowLeft', metaKey: true }); - - expect(onNavigateBack).not.toHaveBeenCalled(); - }); - - it('does not call onNavigateForward with Cmd+Right when canGoForward is false', () => { - const onNavigateForward = vi.fn(); - - render( - - ); - - const container = screen.getByText('current.ts').closest('[tabindex="0"]'); - fireEvent.keyDown(container!, { key: 'ArrowRight', metaKey: true }); - - expect(onNavigateForward).not.toHaveBeenCalled(); - }); - - it('does not call onNavigateBack with Ctrl+Left when canGoBack is false', () => { - const onNavigateBack = vi.fn(); - - render( - - ); - - const container = screen.getByText('current.ts').closest('[tabindex="0"]'); - fireEvent.keyDown(container!, { key: 'ArrowLeft', ctrlKey: true }); - - expect(onNavigateBack).not.toHaveBeenCalled(); - }); - - it('navigates back with Ctrl+Left keyboard shortcut', () => { - const onNavigateBack = vi.fn(); - - render( - - ); - - const container = screen.getByText('current.ts').closest('[tabindex="0"]'); - fireEvent.keyDown(container!, { key: 'ArrowLeft', ctrlKey: true }); - - expect(onNavigateBack).toHaveBeenCalled(); - }); - - it('navigates forward with Ctrl+Right keyboard shortcut', () => { - const onNavigateForward = vi.fn(); - - render( - - ); - - const container = screen.getByText('current.ts').closest('[tabindex="0"]'); - fireEvent.keyDown(container!, { key: 'ArrowRight', ctrlKey: true }); - - expect(onNavigateForward).toHaveBeenCalled(); - }); -}); - -describe('file preview navigation - history popup', () => { - const testFile = { - name: 'current.ts', - content: 'const x = 1;', - path: '/project/current.ts', - }; - - const backHistory = [ - { name: 'first.ts', content: 'const a = 1;', path: '/project/first.ts' }, - { name: 'second.ts', content: 'const b = 2;', path: '/project/second.ts' }, - ]; - - const forwardHistory = [ - { name: 'future1.ts', content: 'const c = 3;', path: '/project/future1.ts' }, - { name: 'future2.ts', content: 'const d = 4;', path: '/project/future2.ts' }, - ]; - - it('shows back history popup on hover', async () => { - render( - - ); - - const backBtn = screen.getByTitle('Go back (⌘←)'); - fireEvent.mouseEnter(backBtn); - - await waitFor(() => { - // Should show the back history items - expect(screen.getByText('second.ts')).toBeInTheDocument(); - expect(screen.getByText('first.ts')).toBeInTheDocument(); - }); - }); - - it('shows forward history popup on hover', async () => { - render( - - ); - - const forwardBtn = screen.getByTitle('Go forward (⌘→)'); - fireEvent.mouseEnter(forwardBtn); - - await waitFor(() => { - // Should show the forward history items - expect(screen.getByText('future1.ts')).toBeInTheDocument(); - expect(screen.getByText('future2.ts')).toBeInTheDocument(); - }); - }); - - it('hides back history popup on mouse leave', async () => { - render( - - ); - - const backBtn = screen.getByTitle('Go back (⌘←)'); - - // Show popup - fireEvent.mouseEnter(backBtn); - await waitFor(() => { - expect(screen.getByText('second.ts')).toBeInTheDocument(); - }); - - // Hide popup - fireEvent.mouseLeave(backBtn); - await waitFor(() => { - expect(screen.queryByText('second.ts')).not.toBeInTheDocument(); - }); - }); - - it('calls onNavigateToIndex when clicking a back history item', async () => { - const onNavigateToIndex = vi.fn(); - - render( - - ); - - const backBtn = screen.getByTitle('Go back (⌘←)'); - fireEvent.mouseEnter(backBtn); - - await waitFor(() => { - expect(screen.getByText('first.ts')).toBeInTheDocument(); - }); - - // Click the first item (index 0 in original history) - const firstItem = screen.getByText('first.ts'); - fireEvent.click(firstItem); - - expect(onNavigateToIndex).toHaveBeenCalledWith(0); - }); - - it('calls onNavigateToIndex when clicking a forward history item', async () => { - const onNavigateToIndex = vi.fn(); - - render( - - ); - - const forwardBtn = screen.getByTitle('Go forward (⌘→)'); - fireEvent.mouseEnter(forwardBtn); - - await waitFor(() => { - expect(screen.getByText('future1.ts')).toBeInTheDocument(); - }); - - // Click the first forward item (index 1 in original history, since current is at 0) - const future1Item = screen.getByText('future1.ts'); - fireEvent.click(future1Item); - - expect(onNavigateToIndex).toHaveBeenCalledWith(1); - }); - - it('shows numbered entries in back history popup', async () => { - render( - - ); - - const backBtn = screen.getByTitle('Go back (⌘←)'); - fireEvent.mouseEnter(backBtn); - - await waitFor(() => { - // History items should be shown with numbering - // The back history is shown in reverse order (newest first) - // With 2 items, actualIndex for first displayed = length - 1 - 0 = 1, so shows "2." - // actualIndex for second displayed = length - 1 - 1 = 0, so shows "1." - expect(screen.getByText('2.')).toBeInTheDocument(); - expect(screen.getByText('1.')).toBeInTheDocument(); - }); - }); - - it('shows numbered entries in forward history popup', async () => { - render( - - ); - - const forwardBtn = screen.getByTitle('Go forward (⌘→)'); - fireEvent.mouseEnter(forwardBtn); - - await waitFor(() => { - // Forward history numbering: actualIndex = currentHistoryIndex + 1 + idx - // For idx=0: actualIndex = 0 + 1 + 0 = 1, shows "2." - // For idx=1: actualIndex = 0 + 1 + 1 = 2, shows "3." - expect(screen.getByText('2.')).toBeInTheDocument(); - expect(screen.getByText('3.')).toBeInTheDocument(); - }); - }); -}); - -describe('file preview navigation - both buttons together', () => { - const testFile = { - name: 'middle.ts', - content: 'const x = 1;', - path: '/project/middle.ts', - }; - - it('renders both back and forward buttons when both are available', () => { - render( - - ); - - const backBtn = screen.getByTitle('Go back (⌘←)'); - const forwardBtn = screen.getByTitle('Go forward (⌘→)'); - expect(backBtn).toBeInTheDocument(); - expect(forwardBtn).toBeInTheDocument(); - expect(backBtn).not.toBeDisabled(); - expect(forwardBtn).not.toBeDisabled(); - }); - - it('disables forward button when only back is available', () => { - // Both buttons render but forward is disabled - render( - - ); - - const backBtn = screen.getByTitle('Go back (⌘←)'); - const forwardBtn = screen.getByTitle('Go forward (⌘→)'); - expect(backBtn).not.toBeDisabled(); - expect(forwardBtn).toBeDisabled(); - }); - - it('disables back button when only forward is available', () => { - // Both buttons render but back is disabled - render( - - ); - - const backBtn = screen.getByTitle('Go back (⌘←)'); - const forwardBtn = screen.getByTitle('Go forward (⌘→)'); - expect(backBtn).toBeDisabled(); - expect(forwardBtn).not.toBeDisabled(); - }); - - it('renders neither button when neither is available', () => { - render( - - ); - - // When both are false, the navigation container doesn't render at all - expect(screen.queryByTitle('Go back (⌘←)')).not.toBeInTheDocument(); - expect(screen.queryByTitle('Go forward (⌘→)')).not.toBeInTheDocument(); - }); -}); - -describe('file preview navigation - non-markdown files', () => { - const tsFile = { - name: 'code.ts', - content: 'const x = 1;', - path: '/project/code.ts', - }; - - it('shows navigation buttons for TypeScript files', () => { - render( - - ); - - expect(screen.getByTitle('Go back (⌘←)')).toBeInTheDocument(); - expect(screen.getByTitle('Go forward (⌘→)')).toBeInTheDocument(); - }); - - it('navigates back with keyboard in TypeScript file', () => { - const onNavigateBack = vi.fn(); - - render( - - ); - - const container = screen.getByText('code.ts').closest('[tabindex="0"]'); - fireEvent.keyDown(container!, { key: 'ArrowLeft', metaKey: true }); - - expect(onNavigateBack).toHaveBeenCalled(); - }); - - it('navigates forward with keyboard in TypeScript file', () => { - const onNavigateForward = vi.fn(); - - render( - - ); - - const container = screen.getByText('code.ts').closest('[tabindex="0"]'); - fireEvent.keyDown(container!, { key: 'ArrowRight', metaKey: true }); - - expect(onNavigateForward).toHaveBeenCalled(); - }); -}); - -describe('file preview navigation - image files', () => { - const imageFile = { - name: 'logo.png', - content: 'data:image/png;base64,abc123', - path: '/project/assets/logo.png', - }; - - it('shows navigation buttons for image files', () => { - render( - - ); - - expect(screen.getByTitle('Go back (⌘←)')).toBeInTheDocument(); - expect(screen.getByTitle('Go forward (⌘→)')).toBeInTheDocument(); - }); - - it('navigates with keyboard from image preview', () => { - const onNavigateBack = vi.fn(); - const onNavigateForward = vi.fn(); - - render( - - ); - - const container = screen.getByText('logo.png').closest('[tabindex="0"]'); - - fireEvent.keyDown(container!, { key: 'ArrowLeft', metaKey: true }); - expect(onNavigateBack).toHaveBeenCalled(); - - fireEvent.keyDown(container!, { key: 'ArrowRight', metaKey: true }); - expect(onNavigateForward).toHaveBeenCalled(); - }); -}); - -describe('file preview navigation - edge cases', () => { - const testFile = { - name: 'test.ts', - content: 'const x = 1;', - path: '/project/test.ts', - }; - - it('does not crash when navigation callbacks are undefined', () => { - render( - - ); - - // Should render without crashing - expect(screen.getByText('test.ts')).toBeInTheDocument(); - - // Buttons might not appear without callbacks, or might be non-functional - // The important thing is no crash - }); - - it('handles empty history arrays', async () => { - render( - - ); - - const backBtn = screen.getByTitle('Go back (⌘←)'); - fireEvent.mouseEnter(backBtn); - - // Should not crash, popup might be empty or not show - await waitFor(() => { - expect(screen.getByText('test.ts')).toBeInTheDocument(); - }); - }); - - it('handles missing currentHistoryIndex gracefully', async () => { - const backHistory = [ - { name: 'first.ts', content: 'const a = 1;', path: '/project/first.ts' }, - ]; - - render( - - ); - - // Should render without crashing - expect(screen.getByText('test.ts')).toBeInTheDocument(); - }); - - it('handles navigation correctly when props are provided', () => { - const onNavigateBack = vi.fn(); - const onNavigateForward = vi.fn(); - - render( - - ); - - const container = screen.getByText('test.ts').closest('[tabindex="0"]'); - - // Back navigation - fireEvent.keyDown(container!, { key: 'ArrowLeft', metaKey: true }); - expect(onNavigateBack).toHaveBeenCalledTimes(1); - - // Forward navigation - fireEvent.keyDown(container!, { key: 'ArrowRight', metaKey: true }); - expect(onNavigateForward).toHaveBeenCalledTimes(1); - }); - - it('handles single file with no history gracefully', () => { - render( - - ); - - // Should render without crashing - expect(screen.getByText('test.ts')).toBeInTheDocument(); - - // No navigation buttons should appear - expect(screen.queryByTitle('Go back (⌘←)')).not.toBeInTheDocument(); - expect(screen.queryByTitle('Go forward (⌘→)')).not.toBeInTheDocument(); - }); }); diff --git a/src/__tests__/renderer/hooks/useAutoRunHandlers.test.ts b/src/__tests__/renderer/hooks/useAutoRunHandlers.test.ts index f0ba9006..c6d6bc8a 100644 --- a/src/__tests__/renderer/hooks/useAutoRunHandlers.test.ts +++ b/src/__tests__/renderer/hooks/useAutoRunHandlers.test.ts @@ -331,7 +331,8 @@ describe('useAutoRunHandlers', () => { expect(window.maestro.autorun.readDoc).toHaveBeenCalledWith( '/test/autorun', - 'Phase 2.md' + 'Phase 2.md', + undefined // sshRemoteId - not set in test session ); const updateFn = mockDeps.setSessions.mock.calls[0][0]; @@ -443,7 +444,7 @@ describe('useAutoRunHandlers', () => { }); expect(mockDeps.setAutoRunIsLoadingDocuments).toHaveBeenCalledWith(true); - expect(window.maestro.autorun.listDocs).toHaveBeenCalledWith('/test/autorun'); + expect(window.maestro.autorun.listDocs).toHaveBeenCalledWith('/test/autorun', undefined); expect(mockDeps.setAutoRunDocumentList).toHaveBeenCalledWith(['Phase 1', 'Phase 2', 'Phase 3']); expect(mockDeps.setAutoRunDocumentTree).toHaveBeenCalled(); expect(mockDeps.setAutoRunIsLoadingDocuments).toHaveBeenCalledWith(false); @@ -625,7 +626,8 @@ describe('useAutoRunHandlers', () => { expect(window.maestro.autorun.writeDoc).toHaveBeenCalledWith( '/test/autorun', 'New Document.md', - '' + '', + undefined // sshRemoteId - not set in test session ); }); @@ -648,7 +650,7 @@ describe('useAutoRunHandlers', () => { await result.current.handleAutoRunCreateDocument('New Doc'); }); - expect(window.maestro.autorun.listDocs).toHaveBeenCalledWith('/test/autorun'); + expect(window.maestro.autorun.listDocs).toHaveBeenCalledWith('/test/autorun', undefined); expect(mockDeps.setAutoRunDocumentList).toHaveBeenCalledWith(['Phase 1', 'New Doc']); expect(mockDeps.setAutoRunDocumentTree).toHaveBeenCalledWith([ { name: 'Phase 1', type: 'file', path: 'Phase 1.md' } @@ -806,7 +808,8 @@ describe('useAutoRunHandlers', () => { expect(count).toBe(3); expect(window.maestro.autorun.readDoc).toHaveBeenCalledWith( '/test/autorun', - 'Tasks.md' + 'Tasks.md', + undefined // sshRemoteId - not set in test session ); }); @@ -991,7 +994,7 @@ describe('useAutoRunHandlers', () => { await result.current.handleAutoRunFolderSelected('/new/folder'); }); - expect(window.maestro.autorun.listDocs).toHaveBeenCalledWith('/new/folder'); + expect(window.maestro.autorun.listDocs).toHaveBeenCalledWith('/new/folder', undefined); expect(mockDeps.setAutoRunDocumentList).toHaveBeenCalledWith(['Phase 1', 'Phase 2']); expect(mockDeps.setAutoRunDocumentTree).toHaveBeenCalled(); expect(mockDeps.setAutoRunSetupModalOpen).toHaveBeenCalledWith(false); @@ -1022,7 +1025,7 @@ describe('useAutoRunHandlers', () => { await result.current.handleAutoRunFolderSelected('/folder'); }); - expect(window.maestro.autorun.readDoc).toHaveBeenCalledWith('/folder', 'First Doc.md'); + expect(window.maestro.autorun.readDoc).toHaveBeenCalledWith('/folder', 'First Doc.md', undefined); const updateFn = mockDeps.setSessions.mock.calls[0][0]; const updatedSessions = updateFn([mockSession]); diff --git a/src/__tests__/renderer/hooks/useFileTreeManagement.test.ts b/src/__tests__/renderer/hooks/useFileTreeManagement.test.ts index 7fefe018..47766c28 100644 --- a/src/__tests__/renderer/hooks/useFileTreeManagement.test.ts +++ b/src/__tests__/renderer/hooks/useFileTreeManagement.test.ts @@ -189,8 +189,9 @@ describe('useFileTreeManagement', () => { await result.current.refreshGitFileState(session.id); }); - // loadFileTree is now called with (path, maxDepth, currentDepth, sshContext) - expect(loadFileTree).toHaveBeenCalledWith('/test/shell', 10, 0, undefined); + // loadFileTree always uses projectRoot (treeRoot), not shellCwd + // But git operations use shellCwd when inputMode is 'terminal' + expect(loadFileTree).toHaveBeenCalledWith('/test/project', 10, 0, undefined); expect(gitService.isRepo).toHaveBeenCalledWith('/test/shell'); expect(gitService.getBranches).toHaveBeenCalledWith('/test/shell'); expect(gitService.getTags).toHaveBeenCalledWith('/test/shell'); diff --git a/src/renderer/components/NewInstanceModal.tsx b/src/renderer/components/NewInstanceModal.tsx index 8438850a..050f1c8e 100644 --- a/src/renderer/components/NewInstanceModal.tsx +++ b/src/renderer/components/NewInstanceModal.tsx @@ -1,5 +1,5 @@ import React, { useState, useEffect, useRef, useMemo, useCallback } from 'react'; -import { Folder, RefreshCw, ChevronRight, AlertTriangle } from 'lucide-react'; +import { Folder, RefreshCw, ChevronRight, AlertTriangle, Copy, Check, X } from 'lucide-react'; import type { AgentConfig, Session, ToolType } from '../types'; import type { SshRemoteConfig, AgentSshRemoteConfig } from '../../shared/types'; import { MODAL_PRIORITIES } from '../constants/modalPriorities'; @@ -856,12 +856,25 @@ export function EditAgentModal({ isOpen, onClose, onSave, theme, session, existi const [customEnvVars, setCustomEnvVars] = useState>({}); const [_customModel, setCustomModel] = useState(''); const [refreshingAgent, setRefreshingAgent] = useState(false); + const [copiedId, setCopiedId] = useState(false); // SSH Remote configuration const [sshRemotes, setSshRemotes] = useState([]); const [sshRemoteConfig, setSshRemoteConfig] = useState(undefined); const nameInputRef = useRef(null); + // Copy session ID to clipboard + const handleCopySessionId = useCallback(async () => { + if (!session) return; + try { + await navigator.clipboard.writeText(session.id); + setCopiedId(true); + setTimeout(() => setCopiedId(false), 2000); + } catch (err) { + console.error('Failed to copy session ID:', err); + } + }, [session]); + // Load agent info, config, custom settings, and models when modal opens useEffect(() => { if (isOpen && session) { @@ -1041,6 +1054,44 @@ export function EditAgentModal({ isOpen, onClose, onSave, theme, session, existi onClose={onClose} width={500} initialFocusRef={nameInputRef} + customHeader={ +
+

+ Edit Agent: {session.name} +

+
+ + +
+
+ } footer={ (null); - // SSH remote awareness - const isRemoteSession = !!session.sshRemoteId; + // SSH remote awareness - check both runtime sshRemoteId and configured sessionSshRemoteConfig + // Note: sshRemoteId is only set after AI agent spawns. For terminal-only SSH sessions, + // we must fall back to sessionSshRemoteConfig.remoteId. See CLAUDE.md "SSH Remote Sessions". + const sshRemoteId = session.sshRemoteId || session.sessionSshRemoteConfig?.remoteId || undefined; + const isRemoteSession = !!sshRemoteId; // Register with layer stack for Escape handling useEffect(() => { @@ -118,7 +121,7 @@ export function WorktreeConfigModal({ setIsValidating(true); setError(null); try { - const exists = await validateDirectory(basePath.trim(), session.sshRemoteId); + const exists = await validateDirectory(basePath.trim(), sshRemoteId); if (!exists) { setError(isRemoteSession ? 'Directory not found on remote server. Please enter a valid path.' diff --git a/src/renderer/hooks/batch/useBatchProcessor.ts b/src/renderer/hooks/batch/useBatchProcessor.ts index fd9eb139..b5129709 100644 --- a/src/renderer/hooks/batch/useBatchProcessor.ts +++ b/src/renderer/hooks/batch/useBatchProcessor.ts @@ -402,7 +402,10 @@ export function useBatchProcessor({ // Set up worktree if enabled using extracted hook // Inject sshRemoteId from session into worktree config for remote worktree operations - const worktreeWithSsh = worktree ? { ...worktree, sshRemoteId: session.sshRemoteId } : undefined; + // Note: sshRemoteId is only set after AI agent spawns. For terminal-only SSH sessions, + // we must fall back to sessionSshRemoteConfig.remoteId. See CLAUDE.md "SSH Remote Sessions". + const sshRemoteId = session.sshRemoteId || session.sessionSshRemoteConfig?.remoteId || undefined; + const worktreeWithSsh = worktree ? { ...worktree, sshRemoteId } : undefined; const worktreeResult = await worktreeManager.setupWorktree(session.cwd, worktreeWithSsh); if (!worktreeResult.success) { window.maestro.logger.log('error', 'Worktree setup failed', 'BatchProcessor', { sessionId, error: worktreeResult.error }); diff --git a/src/renderer/hooks/git/useFileTreeManagement.ts b/src/renderer/hooks/git/useFileTreeManagement.ts index c62a2e5c..9dd6ea36 100644 --- a/src/renderer/hooks/git/useFileTreeManagement.ts +++ b/src/renderer/hooks/git/useFileTreeManagement.ts @@ -9,14 +9,18 @@ import { gitService } from '../../services/git'; /** * Extract SSH context from session for remote file operations. * Returns undefined if no SSH remote is configured. + * + * Note: sshRemoteId is only set after AI agent spawns. For terminal-only SSH sessions, + * we must fall back to sessionSshRemoteConfig.remoteId. See CLAUDE.md "SSH Remote Sessions". */ function getSshContext(session: Session): SshContext | undefined { - if (!session.sshRemoteId) { + const sshRemoteId = session.sshRemoteId || session.sessionSshRemoteConfig?.remoteId || undefined; + if (!sshRemoteId) { return undefined; } return { - sshRemoteId: session.sshRemoteId, - remoteCwd: session.remoteCwd, + sshRemoteId, + remoteCwd: session.remoteCwd || session.sessionSshRemoteConfig?.workingDirOverride, }; } diff --git a/src/renderer/hooks/git/useGitStatusPolling.ts b/src/renderer/hooks/git/useGitStatusPolling.ts index 28014dca..c68c882a 100644 --- a/src/renderer/hooks/git/useGitStatusPolling.ts +++ b/src/renderer/hooks/git/useGitStatusPolling.ts @@ -168,7 +168,9 @@ export function useGitStatusPolling( const isActiveSession = session.id === currentActiveSessionId; // Get SSH remote ID from session for remote git operations - const sshRemoteId = session.sshRemoteId; + // Note: sshRemoteId is only set after AI agent spawns. For terminal-only SSH sessions, + // we must fall back to sessionSshRemoteConfig.remoteId. See CLAUDE.md "SSH Remote Sessions". + const sshRemoteId = session.sshRemoteId || session.sessionSshRemoteConfig?.remoteId || undefined; // For non-active sessions, just get basic status (file count) if (!isActiveSession) {