mirror of
https://github.com/jlengrand/Maestro.git
synced 2026-03-10 08:31:19 +00:00
## CHANGES
- Publish any open file as a GitHub Gist, instantly sharable 🔗 - Smart Share button appears only when GH CLI is ready 🧠 - Quick Actions adds “Publish Document as GitHub Gist” command ⚡ - New high-priority Gist confirmation modal with secret-first default 🔒 - Auto-copies gist URL and shows toast with “Open Gist” action 📋 - Main process IPC now creates gists via `gh gist create` 🛠️ - Command runner now supports stdin input using spawn-based execution 🚰 - Development runs in separate userData directory to avoid lock conflicts 🧪 - Extensive test coverage added for GistPublishModal behavior and a11y 🧰 - New Mintlify docs folder plus contributor guide for docs workflow 📚
This commit is contained in:
13
CLAUDE.md
13
CLAUDE.md
@@ -84,9 +84,14 @@ src/
|
||||
│ ├── autorun-*.md # Auto Run default prompts
|
||||
│ └── index.ts # Central exports
|
||||
│
|
||||
└── shared/ # Shared types and utilities
|
||||
├── types.ts # Common type definitions
|
||||
└── templateVariables.ts # Template variable processing
|
||||
├── shared/ # Shared types and utilities
|
||||
│ ├── types.ts # Common type definitions
|
||||
│ └── templateVariables.ts # Template variable processing
|
||||
│
|
||||
└── docs/ # Mintlify documentation (docs.runmaestro.ai)
|
||||
├── docs.json # Navigation and configuration
|
||||
├── screenshots/ # All documentation screenshots
|
||||
└── *.md # Documentation pages
|
||||
```
|
||||
|
||||
### Key Files for Common Tasks
|
||||
@@ -112,6 +117,8 @@ src/
|
||||
| Modify wizard flow | `src/renderer/components/Wizard/` (see Onboarding Wizard section) |
|
||||
| Add tour step | `src/renderer/components/Wizard/tour/tourSteps.ts` |
|
||||
| Modify file linking | `src/renderer/utils/remarkFileLinks.ts` (remark plugin for `[[wiki]]` and path links) |
|
||||
| Add documentation page | `docs/*.md`, `docs/docs.json` (navigation) |
|
||||
| Add documentation screenshot | `docs/screenshots/` (PNG, kebab-case naming) |
|
||||
|
||||
## Core Patterns
|
||||
|
||||
|
||||
210
CONTRIBUTING.md
210
CONTRIBUTING.md
@@ -31,6 +31,7 @@ See [Performance Guidelines](#performance-guidelines) for specific practices.
|
||||
- [Commit Messages](#commit-messages)
|
||||
- [Pull Request Process](#pull-request-process)
|
||||
- [Building for Release](#building-for-release)
|
||||
- [Documentation](#documentation)
|
||||
|
||||
## Development Setup
|
||||
|
||||
@@ -83,6 +84,11 @@ maestro/
|
||||
│ │ └── templateVariables.ts # Template variable system
|
||||
│ └── web/ # Web interface (Remote Control)
|
||||
│ └── ... # Mobile-optimized React app
|
||||
├── docs/ # Mintlify documentation (hosted at docs.runmaestro.ai)
|
||||
│ ├── docs.json # Mintlify configuration and navigation
|
||||
│ ├── screenshots/ # All documentation screenshots
|
||||
│ ├── assets/ # Logos, icons, and static assets
|
||||
│ └── *.md # Documentation pages
|
||||
├── build/ # Application icons
|
||||
├── .github/workflows/ # CI/CD automation
|
||||
└── dist/ # Build output (generated)
|
||||
@@ -627,12 +633,52 @@ Example: `feat: add context usage visualization`
|
||||
|
||||
## Pull Request Process
|
||||
|
||||
### Before Opening a PR
|
||||
|
||||
All PRs must pass these checks before review:
|
||||
|
||||
1. **Linting passes** — Run both TypeScript and ESLint checks:
|
||||
```bash
|
||||
npm run lint # TypeScript type checking
|
||||
npm run lint:eslint # ESLint code quality
|
||||
```
|
||||
|
||||
2. **Tests pass** — Run the full test suite:
|
||||
```bash
|
||||
npm test
|
||||
```
|
||||
|
||||
3. **Manual testing** — Test affected features in the running app:
|
||||
```bash
|
||||
npm run dev
|
||||
```
|
||||
|
||||
Verify that:
|
||||
- Your feature works as expected
|
||||
- Related features still work (keyboard shortcuts, focus flow, themes)
|
||||
- No console errors in DevTools (`Cmd+Option+I`)
|
||||
- UI renders correctly across different themes (try at least one dark and one light)
|
||||
|
||||
### PR Checklist
|
||||
|
||||
- [ ] Linting passes (`npm run lint && npm run lint:eslint`)
|
||||
- [ ] Tests pass (`npm test`)
|
||||
- [ ] Manually tested affected features
|
||||
- [ ] No new console warnings or errors
|
||||
- [ ] Documentation updated if needed (code comments, README, or `docs/`)
|
||||
- [ ] Commit messages follow [conventional format](#commit-messages)
|
||||
|
||||
### Opening the PR
|
||||
|
||||
1. Create a feature branch from `main`
|
||||
2. Make your changes following the code style
|
||||
3. Test thoroughly (keyboard navigation, themes, focus)
|
||||
4. Update documentation if needed
|
||||
5. Submit PR with clear description
|
||||
6. Wait for review
|
||||
3. Complete the checklist above
|
||||
4. Push and open a PR with a clear description:
|
||||
- What the change does
|
||||
- Why it's needed
|
||||
- How to test it
|
||||
- Screenshots for UI changes
|
||||
5. Wait for review — maintainers may request changes
|
||||
|
||||
## Building for Release
|
||||
|
||||
@@ -686,6 +732,162 @@ git push origin v0.1.0
|
||||
|
||||
GitHub Actions will build for all platforms and create a release.
|
||||
|
||||
## Documentation
|
||||
|
||||
User documentation is hosted on [Mintlify](https://mintlify.com) at **[docs.runmaestro.ai](https://docs.runmaestro.ai)**. The source files live in the `docs/` directory.
|
||||
|
||||
### Documentation Structure
|
||||
|
||||
```
|
||||
docs/
|
||||
├── docs.json # Mintlify configuration (navigation, theme, links)
|
||||
├── index.md # Homepage
|
||||
├── screenshots/ # All documentation screenshots (PNG format)
|
||||
├── assets/ # Logos, icons, favicons
|
||||
├── about/ # Overview and background pages
|
||||
│ └── overview.md
|
||||
└── *.md # Feature and reference pages
|
||||
```
|
||||
|
||||
### Page Organization
|
||||
|
||||
Pages are organized by topic in `docs.json` under `navigation.dropdowns`:
|
||||
|
||||
| Group | Pages | Purpose |
|
||||
|-------|-------|---------|
|
||||
| **Overview** | index, about/overview, features, screenshots | Introduction and feature highlights |
|
||||
| **Getting Started** | installation, getting-started | Onboarding new users |
|
||||
| **Usage** | general-usage, history, context-management, autorun-playbooks, git-worktrees, group-chat, remote-access, slash-commands, speckit-commands, configuration | Feature documentation |
|
||||
| **CLI & Providers** | cli, provider-nuances | Command line and agent-specific docs |
|
||||
| **Reference** | achievements, keyboard-shortcuts, troubleshooting | Quick reference guides |
|
||||
|
||||
### Adding a New Documentation Page
|
||||
|
||||
1. **Create the markdown file** in `docs/`:
|
||||
```markdown
|
||||
---
|
||||
title: My Feature
|
||||
description: A brief description for SEO and navigation.
|
||||
icon: star
|
||||
---
|
||||
|
||||
Content goes here...
|
||||
```
|
||||
|
||||
2. **Add to navigation** in `docs/docs.json`:
|
||||
```json
|
||||
{
|
||||
"group": "Usage",
|
||||
"pages": [
|
||||
"existing-page",
|
||||
"my-feature"
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
3. **Reference from other pages** using relative links:
|
||||
```markdown
|
||||
See [My Feature](./my-feature) for details.
|
||||
```
|
||||
|
||||
### Frontmatter Fields
|
||||
|
||||
Every documentation page needs YAML frontmatter:
|
||||
|
||||
| Field | Required | Description |
|
||||
|-------|----------|-------------|
|
||||
| `title` | Yes | Page title (appears in navigation and browser tab) |
|
||||
| `description` | Yes | Brief description for SEO and page previews |
|
||||
| `icon` | No | [Mintlify icon](https://mintlify.com/docs/content/components/icons) for navigation |
|
||||
|
||||
### Screenshots
|
||||
|
||||
All screenshots are stored in `docs/screenshots/` and referenced with relative paths.
|
||||
|
||||
**Adding a new screenshot:**
|
||||
|
||||
1. **Capture the screenshot** using Maestro's demo mode for clean, consistent visuals:
|
||||
```bash
|
||||
rm -rf /tmp/maestro-demo && npm run dev:demo
|
||||
```
|
||||
|
||||
2. **Save as PNG** in `docs/screenshots/` with a descriptive kebab-case name:
|
||||
```
|
||||
docs/screenshots/my-feature-overview.png
|
||||
docs/screenshots/my-feature-settings.png
|
||||
```
|
||||
|
||||
3. **Reference in markdown** using relative paths:
|
||||
```markdown
|
||||

|
||||
```
|
||||
|
||||
**Screenshot guidelines:**
|
||||
- Use **PNG format** for UI screenshots (better quality for text)
|
||||
- Capture at **standard resolution** (avoid Retina 2x for smaller file sizes, or use 2x for crisp details)
|
||||
- Use a **consistent theme** (Pedurple is used in most existing screenshots)
|
||||
- **Crop to relevant area** — don't include unnecessary whitespace or system UI
|
||||
- Keep file sizes reasonable (compress if over 1MB)
|
||||
|
||||
### Assets
|
||||
|
||||
Static assets like logos and icons live in `docs/assets/`:
|
||||
|
||||
| File | Usage |
|
||||
|------|-------|
|
||||
| `icon.png` | Main logo (used in light and dark mode) |
|
||||
| `icon.ico` | Favicon |
|
||||
| `made-with-maestro.svg` | Badge for README |
|
||||
| `maestro-app-icon.png` | High-res app icon |
|
||||
|
||||
Reference assets with `/assets/` paths in `docs.json` configuration.
|
||||
|
||||
### Mintlify Features
|
||||
|
||||
Documentation supports Mintlify components:
|
||||
|
||||
```markdown
|
||||
<Note>
|
||||
This is an informational note.
|
||||
</Note>
|
||||
|
||||
<Warning>
|
||||
This is a warning message.
|
||||
</Warning>
|
||||
|
||||
<Tip>
|
||||
This is a helpful tip.
|
||||
</Tip>
|
||||
```
|
||||
|
||||
**Embed videos:**
|
||||
```markdown
|
||||
<iframe width="560" height="315"
|
||||
src="https://www.youtube.com/embed/VIDEO_ID"
|
||||
title="Video Title"
|
||||
frameborder="0"
|
||||
allowfullscreen>
|
||||
</iframe>
|
||||
```
|
||||
|
||||
**Tables, code blocks, and standard markdown** all work as expected.
|
||||
|
||||
### Local Preview
|
||||
|
||||
Mintlify provides a CLI for local preview. Install and run:
|
||||
|
||||
```bash
|
||||
npm i -g mintlify
|
||||
cd docs
|
||||
mintlify dev
|
||||
```
|
||||
|
||||
This starts a local server at `http://localhost:3000` with hot reload.
|
||||
|
||||
### Deployment
|
||||
|
||||
Documentation is automatically deployed when changes to `docs/` are pushed to `main`. Mintlify handles the build and hosting.
|
||||
|
||||
## Questions?
|
||||
|
||||
Open a GitHub Discussion or create an Issue.
|
||||
|
||||
@@ -48,6 +48,35 @@ When you open a file, a **breadcrumb trail** appears showing your navigation his
|
||||
|
||||
Files can be edited directly in the preview. Changes are saved automatically when you navigate away or close the preview.
|
||||
|
||||
### Publish as GitHub Gist
|
||||
|
||||
Share files directly as GitHub Gists from the File Preview:
|
||||
|
||||
**Prerequisites:**
|
||||
- [GitHub CLI](https://cli.github.com/) (`gh`) must be installed
|
||||
- You must be authenticated (`gh auth login`)
|
||||
|
||||
**To publish a file:**
|
||||
1. Open a file in File Preview
|
||||
2. Click the **Share icon** (↗) in the header toolbar, or
|
||||
3. Use `Cmd+K` / `Ctrl+K` → "Publish Document as GitHub Gist"
|
||||
|
||||
**Visibility options:**
|
||||
| Option | Description |
|
||||
|--------|-------------|
|
||||
| **Publish Secret** (default) | Creates an unlisted gist — not searchable, only accessible via direct link |
|
||||
| **Publish Public** | Creates a public gist — visible on your profile and searchable |
|
||||
|
||||
The confirmation modal focuses "Publish Secret" by default, so you can press `Enter` to quickly publish. Press `Esc` to cancel.
|
||||
|
||||
**After publishing:**
|
||||
- The gist URL is automatically copied to your clipboard
|
||||
- A toast notification appears with a link to open the gist in your browser
|
||||
|
||||
<Note>
|
||||
The share button only appears when viewing files (not in edit mode) and when GitHub CLI is available and authenticated.
|
||||
</Note>
|
||||
|
||||
### @ File Mentions
|
||||
|
||||
Reference files in your AI prompts using `@` mentions:
|
||||
|
||||
428
src/__tests__/renderer/components/GistPublishModal.test.tsx
Normal file
428
src/__tests__/renderer/components/GistPublishModal.test.tsx
Normal file
@@ -0,0 +1,428 @@
|
||||
/**
|
||||
* Tests for GistPublishModal component
|
||||
*
|
||||
* Tests the core behavior of the gist publishing modal:
|
||||
* - Rendering with filename and options
|
||||
* - Button click handlers (Publish Secret, Publish Public, Cancel)
|
||||
* - Focus management (default focus on Secret button)
|
||||
* - Loading and error states
|
||||
* - API integration
|
||||
* - Layer stack integration
|
||||
* - Accessibility
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
|
||||
import { GistPublishModal } from '../../../renderer/components/GistPublishModal';
|
||||
import { LayerStackProvider } from '../../../renderer/contexts/LayerStackContext';
|
||||
import type { Theme } from '../../../renderer/types';
|
||||
|
||||
// Mock lucide-react
|
||||
vi.mock('lucide-react', () => ({
|
||||
Share2: () => <svg data-testid="share-icon" />,
|
||||
X: () => <svg data-testid="x-icon" />,
|
||||
}));
|
||||
|
||||
// Mock window.maestro.git.createGist
|
||||
const mockCreateGist = vi.fn();
|
||||
|
||||
beforeEach(() => {
|
||||
(window as any).maestro = {
|
||||
git: {
|
||||
createGist: mockCreateGist,
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
// Create a test theme
|
||||
const testTheme: Theme = {
|
||||
id: 'test-theme',
|
||||
name: 'Test Theme',
|
||||
mode: 'dark',
|
||||
colors: {
|
||||
bgMain: '#1e1e1e',
|
||||
bgSidebar: '#252526',
|
||||
bgActivity: '#333333',
|
||||
textMain: '#d4d4d4',
|
||||
textDim: '#808080',
|
||||
accent: '#007acc',
|
||||
accentForeground: '#ffffff',
|
||||
border: '#404040',
|
||||
error: '#f14c4c',
|
||||
warning: '#cca700',
|
||||
success: '#89d185',
|
||||
info: '#3794ff',
|
||||
textInverse: '#000000',
|
||||
},
|
||||
};
|
||||
|
||||
// Helper to render with LayerStackProvider
|
||||
const renderWithLayerStack = (ui: React.ReactElement) => {
|
||||
return render(<LayerStackProvider>{ui}</LayerStackProvider>);
|
||||
};
|
||||
|
||||
describe('GistPublishModal', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockCreateGist.mockResolvedValue({ success: true, gistUrl: 'https://gist.github.com/test123' });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe('rendering', () => {
|
||||
it('renders with filename and all buttons', () => {
|
||||
renderWithLayerStack(
|
||||
<GistPublishModal
|
||||
theme={testTheme}
|
||||
filename="test-file.md"
|
||||
content="# Test content"
|
||||
onClose={vi.fn()}
|
||||
onSuccess={vi.fn()}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByText('Publish as GitHub Gist')).toBeInTheDocument();
|
||||
expect(screen.getByText('test-file.md')).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: 'Cancel' })).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: 'Publish Public' })).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: 'Publish Secret' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders visibility explanations', () => {
|
||||
renderWithLayerStack(
|
||||
<GistPublishModal
|
||||
theme={testTheme}
|
||||
filename="test.js"
|
||||
content="const x = 1;"
|
||||
onClose={vi.fn()}
|
||||
onSuccess={vi.fn()}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByText(/Not searchable, only accessible via direct link/)).toBeInTheDocument();
|
||||
expect(screen.getByText(/Visible on your public profile and searchable/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders share icon in header', () => {
|
||||
renderWithLayerStack(
|
||||
<GistPublishModal
|
||||
theme={testTheme}
|
||||
filename="test.js"
|
||||
content="const x = 1;"
|
||||
onClose={vi.fn()}
|
||||
onSuccess={vi.fn()}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByTestId('share-icon')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('focus management', () => {
|
||||
it('focuses Publish Secret button on mount', async () => {
|
||||
renderWithLayerStack(
|
||||
<GistPublishModal
|
||||
theme={testTheme}
|
||||
filename="test.js"
|
||||
content="const x = 1;"
|
||||
onClose={vi.fn()}
|
||||
onSuccess={vi.fn()}
|
||||
/>
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(document.activeElement).toBe(screen.getByRole('button', { name: 'Publish Secret' }));
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('button handlers', () => {
|
||||
it('calls onClose when Cancel is clicked', () => {
|
||||
const onClose = vi.fn();
|
||||
renderWithLayerStack(
|
||||
<GistPublishModal
|
||||
theme={testTheme}
|
||||
filename="test.js"
|
||||
content="const x = 1;"
|
||||
onClose={onClose}
|
||||
onSuccess={vi.fn()}
|
||||
/>
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Cancel' }));
|
||||
expect(onClose).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('calls createGist with isPublic=false when Publish Secret is clicked', async () => {
|
||||
const onSuccess = vi.fn();
|
||||
renderWithLayerStack(
|
||||
<GistPublishModal
|
||||
theme={testTheme}
|
||||
filename="test.js"
|
||||
content="const x = 1;"
|
||||
onClose={vi.fn()}
|
||||
onSuccess={onSuccess}
|
||||
/>
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Publish Secret' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockCreateGist).toHaveBeenCalledWith('test.js', 'const x = 1;', '', false);
|
||||
});
|
||||
});
|
||||
|
||||
it('calls createGist with isPublic=true when Publish Public is clicked', async () => {
|
||||
const onSuccess = vi.fn();
|
||||
renderWithLayerStack(
|
||||
<GistPublishModal
|
||||
theme={testTheme}
|
||||
filename="test.js"
|
||||
content="const x = 1;"
|
||||
onClose={vi.fn()}
|
||||
onSuccess={onSuccess}
|
||||
/>
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Publish Public' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockCreateGist).toHaveBeenCalledWith('test.js', 'const x = 1;', '', true);
|
||||
});
|
||||
});
|
||||
|
||||
it('calls onSuccess with gistUrl and isPublic=false for secret gist', async () => {
|
||||
const onSuccess = vi.fn();
|
||||
const onClose = vi.fn();
|
||||
mockCreateGist.mockResolvedValue({ success: true, gistUrl: 'https://gist.github.com/secret123' });
|
||||
|
||||
renderWithLayerStack(
|
||||
<GistPublishModal
|
||||
theme={testTheme}
|
||||
filename="test.js"
|
||||
content="const x = 1;"
|
||||
onClose={onClose}
|
||||
onSuccess={onSuccess}
|
||||
/>
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Publish Secret' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onSuccess).toHaveBeenCalledWith('https://gist.github.com/secret123', false);
|
||||
expect(onClose).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it('calls onSuccess with gistUrl and isPublic=true for public gist', async () => {
|
||||
const onSuccess = vi.fn();
|
||||
const onClose = vi.fn();
|
||||
mockCreateGist.mockResolvedValue({ success: true, gistUrl: 'https://gist.github.com/public123' });
|
||||
|
||||
renderWithLayerStack(
|
||||
<GistPublishModal
|
||||
theme={testTheme}
|
||||
filename="test.js"
|
||||
content="const x = 1;"
|
||||
onClose={onClose}
|
||||
onSuccess={onSuccess}
|
||||
/>
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Publish Public' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onSuccess).toHaveBeenCalledWith('https://gist.github.com/public123', true);
|
||||
expect(onClose).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('loading state', () => {
|
||||
it('shows Publishing... text while loading', async () => {
|
||||
// Make createGist hang
|
||||
mockCreateGist.mockImplementation(() => new Promise(() => {}));
|
||||
|
||||
renderWithLayerStack(
|
||||
<GistPublishModal
|
||||
theme={testTheme}
|
||||
filename="test.js"
|
||||
content="const x = 1;"
|
||||
onClose={vi.fn()}
|
||||
onSuccess={vi.fn()}
|
||||
/>
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Publish Secret' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('button', { name: 'Publishing...' })).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('disables all buttons while publishing', async () => {
|
||||
mockCreateGist.mockImplementation(() => new Promise(() => {}));
|
||||
|
||||
renderWithLayerStack(
|
||||
<GistPublishModal
|
||||
theme={testTheme}
|
||||
filename="test.js"
|
||||
content="const x = 1;"
|
||||
onClose={vi.fn()}
|
||||
onSuccess={vi.fn()}
|
||||
/>
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Publish Secret' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('button', { name: 'Cancel' })).toBeDisabled();
|
||||
expect(screen.getByRole('button', { name: 'Publish Public' })).toBeDisabled();
|
||||
expect(screen.getByRole('button', { name: 'Publishing...' })).toBeDisabled();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('error handling', () => {
|
||||
it('displays error message when createGist fails', async () => {
|
||||
mockCreateGist.mockResolvedValue({ success: false, error: 'GitHub CLI not authenticated' });
|
||||
|
||||
renderWithLayerStack(
|
||||
<GistPublishModal
|
||||
theme={testTheme}
|
||||
filename="test.js"
|
||||
content="const x = 1;"
|
||||
onClose={vi.fn()}
|
||||
onSuccess={vi.fn()}
|
||||
/>
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Publish Secret' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('GitHub CLI not authenticated')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('displays error message when createGist throws', async () => {
|
||||
mockCreateGist.mockRejectedValue(new Error('Network error'));
|
||||
|
||||
renderWithLayerStack(
|
||||
<GistPublishModal
|
||||
theme={testTheme}
|
||||
filename="test.js"
|
||||
content="const x = 1;"
|
||||
onClose={vi.fn()}
|
||||
onSuccess={vi.fn()}
|
||||
/>
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Publish Secret' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Network error')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('re-enables buttons after error', async () => {
|
||||
mockCreateGist.mockResolvedValue({ success: false, error: 'Failed' });
|
||||
|
||||
renderWithLayerStack(
|
||||
<GistPublishModal
|
||||
theme={testTheme}
|
||||
filename="test.js"
|
||||
content="const x = 1;"
|
||||
onClose={vi.fn()}
|
||||
onSuccess={vi.fn()}
|
||||
/>
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Publish Secret' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Failed')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Buttons should be re-enabled
|
||||
expect(screen.getByRole('button', { name: 'Cancel' })).not.toBeDisabled();
|
||||
expect(screen.getByRole('button', { name: 'Publish Public' })).not.toBeDisabled();
|
||||
expect(screen.getByRole('button', { name: 'Publish Secret' })).not.toBeDisabled();
|
||||
});
|
||||
|
||||
it('does not call onSuccess or onClose on error', async () => {
|
||||
const onSuccess = vi.fn();
|
||||
const onClose = vi.fn();
|
||||
mockCreateGist.mockResolvedValue({ success: false, error: 'Failed' });
|
||||
|
||||
renderWithLayerStack(
|
||||
<GistPublishModal
|
||||
theme={testTheme}
|
||||
filename="test.js"
|
||||
content="const x = 1;"
|
||||
onClose={onClose}
|
||||
onSuccess={onSuccess}
|
||||
/>
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Publish Secret' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Failed')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
expect(onSuccess).not.toHaveBeenCalled();
|
||||
expect(onClose).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('layer stack integration', () => {
|
||||
it('registers and unregisters without errors', () => {
|
||||
const { unmount } = renderWithLayerStack(
|
||||
<GistPublishModal
|
||||
theme={testTheme}
|
||||
filename="test.js"
|
||||
content="const x = 1;"
|
||||
onClose={vi.fn()}
|
||||
onSuccess={vi.fn()}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByText('Publish as GitHub Gist')).toBeInTheDocument();
|
||||
expect(() => unmount()).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('accessibility', () => {
|
||||
it('has semantic button elements', () => {
|
||||
renderWithLayerStack(
|
||||
<GistPublishModal
|
||||
theme={testTheme}
|
||||
filename="test.js"
|
||||
content="const x = 1;"
|
||||
onClose={vi.fn()}
|
||||
onSuccess={vi.fn()}
|
||||
/>
|
||||
);
|
||||
|
||||
// Cancel, Publish Public, Publish Secret, and X (close) buttons
|
||||
expect(screen.getAllByRole('button').length).toBeGreaterThanOrEqual(3);
|
||||
});
|
||||
|
||||
it('has heading for modal title', () => {
|
||||
renderWithLayerStack(
|
||||
<GistPublishModal
|
||||
theme={testTheme}
|
||||
filename="test.js"
|
||||
content="const x = 1;"
|
||||
onClose={vi.fn()}
|
||||
onSuccess={vi.fn()}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByText('Publish as GitHub Gist')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -99,6 +99,14 @@ if (DEMO_MODE) {
|
||||
console.log(`[DEMO MODE] Using data directory: ${DEMO_DATA_PATH}`);
|
||||
}
|
||||
|
||||
// Development mode: use a separate data directory to allow running alongside production
|
||||
// This prevents database lock conflicts (e.g., Service Worker storage)
|
||||
if (isDevelopment && !DEMO_MODE) {
|
||||
const devDataPath = path.join(app.getPath('userData'), '..', 'maestro-dev');
|
||||
app.setPath('userData', devDataPath);
|
||||
console.log(`[DEV MODE] Using data directory: ${devDataPath}`);
|
||||
}
|
||||
|
||||
// Type definitions
|
||||
interface MaestroSettings {
|
||||
activeThemeId: string;
|
||||
|
||||
@@ -891,5 +891,46 @@ export function registerGitHandlers(): void {
|
||||
}
|
||||
));
|
||||
|
||||
// Create a GitHub Gist from file content
|
||||
// Returns the gist URL on success
|
||||
ipcMain.handle('git:createGist', withIpcErrorLogging(
|
||||
handlerOpts('createGist'),
|
||||
async (filename: string, content: string, description: string, isPublic: boolean, ghPath?: string) => {
|
||||
// Resolve gh CLI path (uses cached detection or custom path)
|
||||
const ghCommand = await resolveGhPath(ghPath);
|
||||
logger.debug(`Using gh CLI for gist creation at: ${ghCommand}`, LOG_CONTEXT);
|
||||
|
||||
// Create gist using gh CLI with stdin for content
|
||||
// gh gist create --filename <name> --desc <desc> [--public] -
|
||||
const args = ['gist', 'create', '--filename', filename];
|
||||
if (description) {
|
||||
args.push('--desc', description);
|
||||
}
|
||||
if (isPublic) {
|
||||
args.push('--public');
|
||||
}
|
||||
args.push('-'); // Read from stdin
|
||||
|
||||
const gistResult = await execFileNoThrow(ghCommand, args, undefined, { input: content });
|
||||
|
||||
if (gistResult.exitCode !== 0) {
|
||||
// Check if gh CLI is not installed
|
||||
if (gistResult.stderr.includes('command not found') || gistResult.stderr.includes('not recognized')) {
|
||||
return { success: false, error: 'GitHub CLI (gh) is not installed. Please install it to create gists.' };
|
||||
}
|
||||
// Check for authentication issues
|
||||
if (gistResult.stderr.includes('not logged') || gistResult.stderr.includes('authentication')) {
|
||||
return { success: false, error: 'GitHub CLI is not authenticated. Please run "gh auth login" first.' };
|
||||
}
|
||||
return { success: false, error: gistResult.stderr || 'Failed to create gist' };
|
||||
}
|
||||
|
||||
// The gist URL is typically in stdout
|
||||
const gistUrl = gistResult.stdout.trim();
|
||||
logger.info(`${LOG_CONTEXT} Created gist: ${gistUrl}`);
|
||||
return { success: true, gistUrl };
|
||||
}
|
||||
));
|
||||
|
||||
logger.debug(`${LOG_CONTEXT} Git IPC handlers registered`);
|
||||
}
|
||||
|
||||
@@ -411,6 +411,13 @@ contextBridge.exposeInMainWorld('maestro', {
|
||||
installed: boolean;
|
||||
authenticated: boolean;
|
||||
}>,
|
||||
// Create a GitHub Gist from file content
|
||||
createGist: (filename: string, content: string, description: string, isPublic: boolean, ghPath?: string) =>
|
||||
ipcRenderer.invoke('git:createGist', filename, content, description, isPublic, ghPath) as Promise<{
|
||||
success: boolean;
|
||||
gistUrl?: string;
|
||||
error?: string;
|
||||
}>,
|
||||
// List all worktrees for a git repository
|
||||
listWorktrees: (cwd: string) =>
|
||||
ipcRenderer.invoke('git:listWorktrees', cwd) as Promise<{
|
||||
@@ -1460,6 +1467,11 @@ export interface MaestroAPI {
|
||||
installed: boolean;
|
||||
authenticated: boolean;
|
||||
}>;
|
||||
createGist: (filename: string, content: string, description: string, isPublic: boolean, ghPath?: string) => Promise<{
|
||||
success: boolean;
|
||||
gistUrl?: string;
|
||||
error?: string;
|
||||
}>;
|
||||
listWorktrees: (cwd: string) => Promise<{
|
||||
worktrees: Array<{
|
||||
path: string;
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
import { execFile } from 'child_process';
|
||||
import { execFile, spawn } from 'child_process';
|
||||
import { promisify } from 'util';
|
||||
import * as path from 'path';
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
export interface ExecOptions {
|
||||
input?: string; // Content to write to stdin
|
||||
}
|
||||
|
||||
// Maximum buffer size for command output (10MB)
|
||||
const EXEC_MAX_BUFFER = 10 * 1024 * 1024;
|
||||
|
||||
@@ -43,13 +47,37 @@ function needsWindowsShell(command: string): boolean {
|
||||
*
|
||||
* On Windows, batch files and commands without extensions are handled
|
||||
* by enabling shell mode, since execFile cannot directly execute them.
|
||||
*
|
||||
* @param command - The command to execute
|
||||
* @param args - Arguments to pass to the command
|
||||
* @param cwd - Working directory for the command
|
||||
* @param options - Additional options (input for stdin, env for environment)
|
||||
*/
|
||||
export async function execFileNoThrow(
|
||||
command: string,
|
||||
args: string[] = [],
|
||||
cwd?: string,
|
||||
env?: NodeJS.ProcessEnv
|
||||
options?: ExecOptions | NodeJS.ProcessEnv
|
||||
): Promise<ExecResult> {
|
||||
// Handle backward compatibility: options can be env (old signature) or ExecOptions (new)
|
||||
let env: NodeJS.ProcessEnv | undefined;
|
||||
let input: string | undefined;
|
||||
|
||||
if (options) {
|
||||
if ('input' in options) {
|
||||
// New signature with ExecOptions
|
||||
input = options.input;
|
||||
} else {
|
||||
// Old signature with just env
|
||||
env = options as NodeJS.ProcessEnv;
|
||||
}
|
||||
}
|
||||
|
||||
// If input is provided, use spawn instead of execFile to write to stdin
|
||||
if (input !== undefined) {
|
||||
return execFileWithInput(command, args, cwd, input);
|
||||
}
|
||||
|
||||
try {
|
||||
// On Windows, some commands need shell execution
|
||||
// This is safe because we're executing a specific file path, not user input
|
||||
@@ -78,3 +106,58 @@ export async function execFileNoThrow(
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute a command with input written to stdin
|
||||
* Uses spawn to allow writing to the process stdin
|
||||
*/
|
||||
async function execFileWithInput(
|
||||
command: string,
|
||||
args: string[],
|
||||
cwd: string | undefined,
|
||||
input: string
|
||||
): Promise<ExecResult> {
|
||||
return new Promise((resolve) => {
|
||||
const isWindows = process.platform === 'win32';
|
||||
const useShell = isWindows && needsWindowsShell(command);
|
||||
|
||||
const child = spawn(command, args, {
|
||||
cwd,
|
||||
shell: useShell,
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
});
|
||||
|
||||
let stdout = '';
|
||||
let stderr = '';
|
||||
|
||||
child.stdout?.on('data', (data) => {
|
||||
stdout += data.toString();
|
||||
});
|
||||
|
||||
child.stderr?.on('data', (data) => {
|
||||
stderr += data.toString();
|
||||
});
|
||||
|
||||
child.on('close', (code) => {
|
||||
resolve({
|
||||
stdout,
|
||||
stderr,
|
||||
exitCode: code ?? 1,
|
||||
});
|
||||
});
|
||||
|
||||
child.on('error', (err) => {
|
||||
resolve({
|
||||
stdout: '',
|
||||
stderr: err.message,
|
||||
exitCode: 1,
|
||||
});
|
||||
});
|
||||
|
||||
// Write input to stdin and close it
|
||||
if (child.stdin) {
|
||||
child.stdin.write(input);
|
||||
child.stdin.end();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -18,6 +18,7 @@ import { AppOverlays } from './components/AppOverlays';
|
||||
import { PlaygroundPanel } from './components/PlaygroundPanel';
|
||||
import { DebugWizardModal } from './components/DebugWizardModal';
|
||||
import { DebugPackageModal } from './components/DebugPackageModal';
|
||||
import { GistPublishModal } from './components/GistPublishModal';
|
||||
import { MaestroWizard, useWizard, WizardResumeModal, AUTO_RUN_FOLDER_NAME } from './components/Wizard';
|
||||
import { TourOverlay } from './components/Wizard/tour';
|
||||
import { CONDUCTOR_BADGES, getBadgeForTime } from './constants/conductorBadges';
|
||||
@@ -381,6 +382,10 @@ function MaestroConsoleInner() {
|
||||
const [fileTreeFilter, setFileTreeFilter] = useState('');
|
||||
const [fileTreeFilterOpen, setFileTreeFilterOpen] = useState(false);
|
||||
|
||||
// GitHub CLI availability (for gist publishing)
|
||||
const [ghCliAvailable, setGhCliAvailable] = useState(false);
|
||||
const [gistPublishModalOpen, setGistPublishModalOpen] = useState(false);
|
||||
|
||||
// Note: Git Diff State, Tour Overlay State, and Git Log Viewer State are now from ModalContext
|
||||
|
||||
// Renaming State
|
||||
@@ -741,17 +746,22 @@ function MaestroConsoleInner() {
|
||||
// Use a ref to prevent duplicate execution in React Strict Mode
|
||||
const sessionLoadStarted = useRef(false);
|
||||
useEffect(() => {
|
||||
console.log('[App] Session load useEffect triggered');
|
||||
// Guard against duplicate execution in React Strict Mode
|
||||
if (sessionLoadStarted.current) {
|
||||
console.log('[App] Session load already started, skipping');
|
||||
return;
|
||||
}
|
||||
sessionLoadStarted.current = true;
|
||||
console.log('[App] Starting loadSessionsAndGroups');
|
||||
|
||||
const loadSessionsAndGroups = async () => {
|
||||
let _hasSessionsLoaded = false;
|
||||
|
||||
try {
|
||||
console.log('[App] About to call sessions.getAll()');
|
||||
const savedSessions = await window.maestro.sessions.getAll();
|
||||
console.log('[App] Got sessions:', savedSessions?.length ?? 0);
|
||||
const savedGroups = await window.maestro.groups.getAll();
|
||||
|
||||
// Handle sessions
|
||||
@@ -806,13 +816,24 @@ function MaestroConsoleInner() {
|
||||
// Hide splash screen only when both settings and sessions have fully loaded
|
||||
// This prevents theme flash on initial render
|
||||
useEffect(() => {
|
||||
console.log('[App] Splash check - settingsLoaded:', settingsLoaded, 'sessionsLoaded:', sessionsLoaded);
|
||||
if (settingsLoaded && sessionsLoaded) {
|
||||
console.log('[App] Both loaded, hiding splash');
|
||||
if (typeof window.__hideSplash === 'function') {
|
||||
window.__hideSplash();
|
||||
}
|
||||
}
|
||||
}, [settingsLoaded, sessionsLoaded]);
|
||||
|
||||
// Check GitHub CLI availability for gist publishing
|
||||
useEffect(() => {
|
||||
window.maestro.git.checkGhCli().then(status => {
|
||||
setGhCliAvailable(status.installed && status.authenticated);
|
||||
}).catch(() => {
|
||||
setGhCliAvailable(false);
|
||||
});
|
||||
}, []);
|
||||
|
||||
// Expose debug helpers to window for console access
|
||||
// No dependency array - always keep functions fresh
|
||||
(window as any).__maestroDebug = {
|
||||
@@ -8250,6 +8271,9 @@ function MaestroConsoleInner() {
|
||||
autoRunSelectedDocument={activeSession?.autoRunSelectedFile ?? null}
|
||||
autoRunCompletedTaskCount={rightPanelRef.current?.getAutoRunCompletedTaskCount() ?? 0}
|
||||
onAutoRunResetTasks={handleQuickActionsAutoRunResetTasks}
|
||||
isFilePreviewOpen={previewFile !== null}
|
||||
ghCliAvailable={ghCliAvailable}
|
||||
onPublishGist={() => setGistPublishModalOpen(true)}
|
||||
lightboxImage={lightboxImage}
|
||||
lightboxImages={lightboxImages}
|
||||
stagedImages={stagedImages}
|
||||
@@ -8390,6 +8414,29 @@ function MaestroConsoleInner() {
|
||||
onClose={() => setDebugWizardModalOpen(false)}
|
||||
/>
|
||||
|
||||
{/* --- GIST PUBLISH MODAL --- */}
|
||||
{gistPublishModalOpen && previewFile && (
|
||||
<GistPublishModal
|
||||
theme={theme}
|
||||
filename={previewFile.name}
|
||||
content={previewFile.content}
|
||||
onClose={() => setGistPublishModalOpen(false)}
|
||||
onSuccess={(gistUrl, isPublic) => {
|
||||
// Copy the gist URL to clipboard
|
||||
navigator.clipboard.writeText(gistUrl);
|
||||
// Show a toast notification
|
||||
addToast({
|
||||
type: 'success',
|
||||
title: 'Gist Published',
|
||||
message: `${isPublic ? 'Public' : 'Secret'} gist created! URL copied to clipboard.`,
|
||||
duration: 5000,
|
||||
actionUrl: gistUrl,
|
||||
actionLabel: 'Open Gist',
|
||||
});
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* NOTE: All modals are now rendered via the unified <AppModals /> component above */}
|
||||
|
||||
{/* --- EMPTY STATE VIEW (when no sessions) --- */}
|
||||
@@ -9206,6 +9253,8 @@ function MaestroConsoleInner() {
|
||||
onKeyboardMasteryLevelUp(result.newLevel);
|
||||
}
|
||||
}}
|
||||
ghCliAvailable={ghCliAvailable}
|
||||
onPublishGist={() => setGistPublishModalOpen(true)}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
@@ -747,6 +747,11 @@ export interface AppUtilityModalsProps {
|
||||
autoRunCompletedTaskCount: number;
|
||||
onAutoRunResetTasks: () => void;
|
||||
|
||||
// Gist publishing (for QuickActionsModal)
|
||||
isFilePreviewOpen: boolean;
|
||||
ghCliAvailable: boolean;
|
||||
onPublishGist?: () => void;
|
||||
|
||||
// LightboxModal
|
||||
lightboxImage: string | null;
|
||||
lightboxImages: string[];
|
||||
@@ -908,6 +913,10 @@ export function AppUtilityModals({
|
||||
autoRunSelectedDocument,
|
||||
autoRunCompletedTaskCount,
|
||||
onAutoRunResetTasks,
|
||||
// Gist publishing
|
||||
isFilePreviewOpen,
|
||||
ghCliAvailable,
|
||||
onPublishGist,
|
||||
// LightboxModal
|
||||
lightboxImage,
|
||||
lightboxImages,
|
||||
@@ -1046,6 +1055,9 @@ export function AppUtilityModals({
|
||||
autoRunSelectedDocument={autoRunSelectedDocument}
|
||||
autoRunCompletedTaskCount={autoRunCompletedTaskCount}
|
||||
onAutoRunResetTasks={onAutoRunResetTasks}
|
||||
isFilePreviewOpen={isFilePreviewOpen}
|
||||
ghCliAvailable={ghCliAvailable}
|
||||
onPublishGist={onPublishGist}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -1719,6 +1731,10 @@ export interface AppModalsProps {
|
||||
autoRunSelectedDocument: string | null;
|
||||
autoRunCompletedTaskCount: number;
|
||||
onAutoRunResetTasks: () => void;
|
||||
// Gist publishing
|
||||
isFilePreviewOpen: boolean;
|
||||
ghCliAvailable: boolean;
|
||||
onPublishGist?: () => void;
|
||||
lightboxImage: string | null;
|
||||
lightboxImages: string[];
|
||||
stagedImages: string[];
|
||||
@@ -1981,6 +1997,10 @@ export function AppModals(props: AppModalsProps) {
|
||||
autoRunSelectedDocument,
|
||||
autoRunCompletedTaskCount,
|
||||
onAutoRunResetTasks,
|
||||
// Gist publishing
|
||||
isFilePreviewOpen,
|
||||
ghCliAvailable,
|
||||
onPublishGist,
|
||||
lightboxImage,
|
||||
lightboxImages,
|
||||
stagedImages,
|
||||
@@ -2261,6 +2281,9 @@ export function AppModals(props: AppModalsProps) {
|
||||
autoRunSelectedDocument={autoRunSelectedDocument}
|
||||
autoRunCompletedTaskCount={autoRunCompletedTaskCount}
|
||||
onAutoRunResetTasks={onAutoRunResetTasks}
|
||||
isFilePreviewOpen={isFilePreviewOpen}
|
||||
ghCliAvailable={ghCliAvailable}
|
||||
onPublishGist={onPublishGist}
|
||||
lightboxImage={lightboxImage}
|
||||
lightboxImages={lightboxImages}
|
||||
stagedImages={stagedImages}
|
||||
|
||||
@@ -5,7 +5,7 @@ import rehypeRaw from 'rehype-raw';
|
||||
import rehypeSlug from 'rehype-slug';
|
||||
import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter';
|
||||
import { vscDarkPlus } from 'react-syntax-highlighter/dist/esm/styles/prism';
|
||||
import { FileCode, X, Eye, ChevronUp, ChevronDown, ChevronLeft, ChevronRight, Clipboard, Loader2, Image, Globe, Save, Edit, FolderOpen, AlertTriangle } from 'lucide-react';
|
||||
import { FileCode, X, Eye, ChevronUp, ChevronDown, ChevronLeft, ChevronRight, Clipboard, Loader2, Image, Globe, Save, Edit, FolderOpen, AlertTriangle, Share2 } from 'lucide-react';
|
||||
import { visit } from 'unist-util-visit';
|
||||
import { useLayerStack } from '../contexts/LayerStackContext';
|
||||
import { MODAL_PRIORITIES } from '../constants/modalPriorities';
|
||||
@@ -59,6 +59,10 @@ interface FilePreviewProps {
|
||||
onOpenFuzzySearch?: () => void;
|
||||
/** Callback to track shortcut usage for keyboard mastery */
|
||||
onShortcutUsed?: (shortcutId: string) => void;
|
||||
/** Whether GitHub CLI is available for gist publishing */
|
||||
ghCliAvailable?: boolean;
|
||||
/** Callback to open gist publish modal */
|
||||
onPublishGist?: () => void;
|
||||
}
|
||||
|
||||
// Get language from filename extension
|
||||
@@ -392,7 +396,7 @@ function remarkHighlight() {
|
||||
};
|
||||
}
|
||||
|
||||
export function FilePreview({ file, onClose, theme, markdownEditMode, setMarkdownEditMode, onSave, shortcuts, fileTree, cwd, onFileClick, canGoBack, canGoForward, onNavigateBack, onNavigateForward, backHistory, forwardHistory, onNavigateToIndex, currentHistoryIndex, onOpenFuzzySearch, onShortcutUsed }: FilePreviewProps) {
|
||||
export function FilePreview({ file, onClose, theme, markdownEditMode, setMarkdownEditMode, onSave, shortcuts, fileTree, cwd, onFileClick, canGoBack, canGoForward, onNavigateBack, onNavigateForward, backHistory, forwardHistory, onNavigateToIndex, currentHistoryIndex, onOpenFuzzySearch, onShortcutUsed, ghCliAvailable, onPublishGist }: FilePreviewProps) {
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [searchOpen, setSearchOpen] = useState(false);
|
||||
const [showCopyNotification, setShowCopyNotification] = useState(false);
|
||||
@@ -1157,6 +1161,17 @@ export function FilePreview({ file, onClose, theme, markdownEditMode, setMarkdow
|
||||
>
|
||||
<Clipboard className="w-4 h-4" />
|
||||
</button>
|
||||
{/* Publish as Gist button - only show if gh CLI is available and not in edit mode */}
|
||||
{ghCliAvailable && !markdownEditMode && onPublishGist && !isImage && (
|
||||
<button
|
||||
onClick={onPublishGist}
|
||||
className="p-2 rounded hover:bg-white/10 transition-colors"
|
||||
style={{ color: theme.colors.textDim }}
|
||||
title="Publish as GitHub Gist"
|
||||
>
|
||||
<Share2 className="w-4 h-4" />
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={copyPathToClipboard}
|
||||
className="p-2 rounded hover:bg-white/10 transition-colors"
|
||||
|
||||
151
src/renderer/components/GistPublishModal.tsx
Normal file
151
src/renderer/components/GistPublishModal.tsx
Normal file
@@ -0,0 +1,151 @@
|
||||
import React, { useRef, useState, useCallback } from 'react';
|
||||
import { Share2 } from 'lucide-react';
|
||||
import type { Theme } from '../types';
|
||||
import { MODAL_PRIORITIES } from '../constants/modalPriorities';
|
||||
import { Modal } from './ui/Modal';
|
||||
|
||||
interface GistPublishModalProps {
|
||||
theme: Theme;
|
||||
filename: string;
|
||||
content: string;
|
||||
onClose: () => void;
|
||||
onSuccess: (gistUrl: string, isPublic: boolean) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Modal for publishing a file as a GitHub Gist.
|
||||
* Offers three options: Publish Secret (default), Publish Public, or Cancel.
|
||||
* The default option (Secret) is focused for Enter key submission.
|
||||
*/
|
||||
export function GistPublishModal({
|
||||
theme,
|
||||
filename,
|
||||
content,
|
||||
onClose,
|
||||
onSuccess,
|
||||
}: GistPublishModalProps) {
|
||||
const secretButtonRef = useRef<HTMLButtonElement>(null);
|
||||
const [isPublishing, setIsPublishing] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const handlePublish = useCallback(async (isPublic: boolean) => {
|
||||
setIsPublishing(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const result = await window.maestro.git.createGist(
|
||||
filename,
|
||||
content,
|
||||
'', // No description - file name serves as context
|
||||
isPublic
|
||||
);
|
||||
|
||||
if (result.success && result.gistUrl) {
|
||||
onSuccess(result.gistUrl, isPublic);
|
||||
onClose();
|
||||
} else {
|
||||
setError(result.error || 'Failed to create gist');
|
||||
setIsPublishing(false);
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to create gist');
|
||||
setIsPublishing(false);
|
||||
}
|
||||
}, [filename, content, onSuccess, onClose]);
|
||||
|
||||
const handlePublishSecret = useCallback(() => {
|
||||
handlePublish(false);
|
||||
}, [handlePublish]);
|
||||
|
||||
const handlePublishPublic = useCallback(() => {
|
||||
handlePublish(true);
|
||||
}, [handlePublish]);
|
||||
|
||||
return (
|
||||
<Modal
|
||||
theme={theme}
|
||||
title="Publish as GitHub Gist"
|
||||
headerIcon={<Share2 className="w-4 h-4" style={{ color: theme.colors.accent }} />}
|
||||
priority={MODAL_PRIORITIES.GIST_PUBLISH}
|
||||
onClose={onClose}
|
||||
width={450}
|
||||
zIndex={10000}
|
||||
initialFocusRef={secretButtonRef}
|
||||
footer={
|
||||
<div className="flex items-center justify-between w-full">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
disabled={isPublishing}
|
||||
className="px-4 py-2 rounded border hover:bg-white/5 transition-colors outline-none focus:ring-2 focus:ring-offset-1"
|
||||
style={{
|
||||
borderColor: theme.colors.border,
|
||||
color: theme.colors.textMain,
|
||||
opacity: isPublishing ? 0.5 : 1,
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handlePublishPublic}
|
||||
disabled={isPublishing}
|
||||
className="px-4 py-2 rounded border hover:bg-white/5 transition-colors outline-none focus:ring-2 focus:ring-offset-1"
|
||||
style={{
|
||||
borderColor: theme.colors.border,
|
||||
color: theme.colors.textMain,
|
||||
opacity: isPublishing ? 0.5 : 1,
|
||||
}}
|
||||
>
|
||||
Publish Public
|
||||
</button>
|
||||
<button
|
||||
ref={secretButtonRef}
|
||||
type="button"
|
||||
onClick={handlePublishSecret}
|
||||
disabled={isPublishing}
|
||||
className="px-4 py-2 rounded transition-colors outline-none focus:ring-2 focus:ring-offset-1"
|
||||
style={{
|
||||
backgroundColor: theme.colors.accent,
|
||||
color: theme.colors.accentForeground,
|
||||
opacity: isPublishing ? 0.5 : 1,
|
||||
}}
|
||||
>
|
||||
{isPublishing ? 'Publishing...' : 'Publish Secret'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div className="space-y-4">
|
||||
<p className="text-sm leading-relaxed" style={{ color: theme.colors.textMain }}>
|
||||
Publish <span className="font-medium" style={{ color: theme.colors.accent }}>{filename}</span> as a GitHub Gist?
|
||||
</p>
|
||||
|
||||
<div className="text-xs space-y-2" style={{ color: theme.colors.textDim }}>
|
||||
<p>
|
||||
<span className="font-medium" style={{ color: theme.colors.textMain }}>Secret:</span>{' '}
|
||||
Not searchable, only accessible via direct link
|
||||
</p>
|
||||
<p>
|
||||
<span className="font-medium" style={{ color: theme.colors.textMain }}>Public:</span>{' '}
|
||||
Visible on your public profile and searchable
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div
|
||||
className="px-3 py-2 rounded text-sm"
|
||||
style={{
|
||||
backgroundColor: `${theme.colors.error}20`,
|
||||
color: theme.colors.error,
|
||||
}}
|
||||
>
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -219,6 +219,10 @@ interface MainPanelProps {
|
||||
|
||||
// Keyboard mastery tracking
|
||||
onShortcutUsed?: (shortcutId: string) => void;
|
||||
|
||||
// Gist publishing
|
||||
ghCliAvailable?: boolean;
|
||||
onPublishGist?: () => void;
|
||||
}
|
||||
|
||||
// PERFORMANCE: Wrap with React.memo to prevent re-renders when parent (App.tsx) re-renders
|
||||
@@ -1009,6 +1013,8 @@ export const MainPanel = React.memo(forwardRef<MainPanelHandle, MainPanelProps>(
|
||||
onNavigateToIndex={props.onNavigateToIndex}
|
||||
onOpenFuzzySearch={props.onOpenFuzzySearch}
|
||||
onShortcutUsed={props.onShortcutUsed}
|
||||
ghCliAvailable={props.ghCliAvailable}
|
||||
onPublishGist={props.onPublishGist}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
|
||||
@@ -99,6 +99,10 @@ interface QuickActionsModalProps {
|
||||
onCloseOtherTabs?: () => void;
|
||||
onCloseTabsLeft?: () => void;
|
||||
onCloseTabsRight?: () => void;
|
||||
// Gist publishing
|
||||
isFilePreviewOpen?: boolean;
|
||||
ghCliAvailable?: boolean;
|
||||
onPublishGist?: () => void;
|
||||
}
|
||||
|
||||
export function QuickActionsModal(props: QuickActionsModalProps) {
|
||||
@@ -118,7 +122,8 @@ export function QuickActionsModal(props: QuickActionsModalProps) {
|
||||
hasActiveSessionCapability, onOpenMergeSession, onOpenSendToAgent, onOpenCreatePR,
|
||||
onSummarizeAndContinue, canSummarizeActiveTab,
|
||||
autoRunSelectedDocument, autoRunCompletedTaskCount, onAutoRunResetTasks,
|
||||
onCloseAllTabs, onCloseOtherTabs, onCloseTabsLeft, onCloseTabsRight
|
||||
onCloseAllTabs, onCloseOtherTabs, onCloseTabsLeft, onCloseTabsRight,
|
||||
isFilePreviewOpen, ghCliAvailable, onPublishGist
|
||||
} = props;
|
||||
|
||||
const [search, setSearch] = useState('');
|
||||
@@ -396,6 +401,16 @@ export function QuickActionsModal(props: QuickActionsModalProps) {
|
||||
}
|
||||
}] : []),
|
||||
...(setFuzzyFileSearchOpen ? [{ id: 'fuzzyFileSearch', label: 'Fuzzy File Search', shortcut: shortcuts.fuzzyFileSearch, action: () => { setFuzzyFileSearchOpen(true); setQuickActionOpen(false); } }] : []),
|
||||
// Publish document as GitHub Gist - only when file preview is open, gh CLI is available, and not in edit mode
|
||||
...(isFilePreviewOpen && ghCliAvailable && onPublishGist && !markdownEditMode ? [{
|
||||
id: 'publishGist',
|
||||
label: 'Publish Document as GitHub Gist',
|
||||
subtext: 'Share current file as a public or secret gist',
|
||||
action: () => {
|
||||
onPublishGist();
|
||||
setQuickActionOpen(false);
|
||||
}
|
||||
}] : []),
|
||||
// Group Chat commands - only show when at least 2 AI agents exist
|
||||
...(onNewGroupChat && sessions.filter(s => s.toolType !== 'terminal').length >= 2 ? [{ id: 'newGroupChat', label: 'New Group Chat', action: () => { onNewGroupChat(); setQuickActionOpen(false); } }] : []),
|
||||
...(activeGroupChatId && onCloseGroupChat ? [{ id: 'closeGroupChat', label: 'Close Group Chat', action: () => { onCloseGroupChat(); setQuickActionOpen(false); } }] : []),
|
||||
|
||||
@@ -32,6 +32,9 @@ export const MODAL_PRIORITIES = {
|
||||
/** Confirmation dialogs - highest priority, always on top */
|
||||
CONFIRM: 1000,
|
||||
|
||||
/** Gist publish confirmation modal - high priority */
|
||||
GIST_PUBLISH: 980,
|
||||
|
||||
/** Playbook delete confirmation - high priority, appears on top of BatchRunner */
|
||||
PLAYBOOK_DELETE_CONFIRM: 950,
|
||||
|
||||
|
||||
5
src/renderer/global.d.ts
vendored
5
src/renderer/global.d.ts
vendored
@@ -283,6 +283,11 @@ interface MaestroAPI {
|
||||
tags: (cwd: string) => Promise<{ tags: string[] }>;
|
||||
commitCount: (cwd: string) => Promise<{ count: number; error: string | null }>;
|
||||
checkGhCli: (ghPath?: string) => Promise<{ installed: boolean; authenticated: boolean }>;
|
||||
createGist: (filename: string, content: string, description: string, isPublic: boolean, ghPath?: string) => Promise<{
|
||||
success: boolean;
|
||||
gistUrl?: string;
|
||||
error?: string;
|
||||
}>;
|
||||
// Git worktree operations for Auto Run parallelization
|
||||
worktreeInfo: (worktreePath: string) => Promise<{
|
||||
success: boolean;
|
||||
|
||||
@@ -1056,8 +1056,12 @@ export function useSettings(): UseSettingsReturn {
|
||||
|
||||
// Load settings from electron-store on mount
|
||||
useEffect(() => {
|
||||
console.log('[Settings] useEffect triggered, about to call loadSettings');
|
||||
const loadSettings = async () => {
|
||||
console.log('[Settings] loadSettings started');
|
||||
try {
|
||||
const savedEnterToSendAI = await window.maestro.settings.get('enterToSendAI');
|
||||
console.log('[Settings] Got first setting');
|
||||
const savedEnterToSendTerminal = await window.maestro.settings.get('enterToSendTerminal');
|
||||
const savedDefaultSaveToHistory = await window.maestro.settings.get('defaultSaveToHistory');
|
||||
const savedDefaultShowThinking = await window.maestro.settings.get('defaultShowThinking');
|
||||
@@ -1324,8 +1328,12 @@ export function useSettings(): UseSettingsReturn {
|
||||
setKeyboardMasteryStatsState({ ...DEFAULT_KEYBOARD_MASTERY_STATS, ...(savedKeyboardMasteryStats as Partial<KeyboardMasteryStats>) });
|
||||
}
|
||||
|
||||
// Mark settings as loaded
|
||||
setSettingsLoaded(true);
|
||||
} catch (error) {
|
||||
console.error('[Settings] Failed to load settings:', error);
|
||||
} finally {
|
||||
// Mark settings as loaded even if there was an error (use defaults)
|
||||
setSettingsLoaded(true);
|
||||
}
|
||||
};
|
||||
loadSettings();
|
||||
}, []);
|
||||
|
||||
Reference in New Issue
Block a user