## 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:
Pedram Amini
2025-12-28 08:05:29 -06:00
parent 24e2376373
commit f6f967b0af
17 changed files with 1099 additions and 14 deletions

View File

@@ -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

View File

@@ -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
![My Feature](./screenshots/my-feature-overview.png)
```
**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.

View File

@@ -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:

View 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();
});
});
});

View File

@@ -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;

View File

@@ -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`);
}

View File

@@ -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;

View File

@@ -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();
}
});
}

View File

@@ -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)}
/>
)}

View File

@@ -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}

View File

@@ -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"

View 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>
);
}

View File

@@ -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>
) : (

View File

@@ -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); } }] : []),

View File

@@ -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,

View File

@@ -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;

View File

@@ -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();
}, []);