## CHANGES
- Generate playbooks mid-session with the new Inline Wizard flow 🧙 - Add confidence gauge and organized wizard subfolders for generated docs 📈 - Launch **Maestro Symphony** to donate tokens and ship OSS PRs 🎵 - Browse/filter/search Symphony projects and issues directly in-app 🔎 - Auto-clone repos, run Auto Run docs, and open draft PRs automatically 🚀 - Track Symphony contributions with active/history/stats dashboards 📊 - Make leaderboard submissions multi-device safe using delta-based aggregation 🔁 - Stop image flicker with synchronous cache-backed Markdown image loading 🖼️ - Debounce Auto Run preview scroll persistence to cut noisy re-renders ⏱️ - Rename/delete in File Explorer updates tree instantly—no full refresh ⚡
@@ -58,6 +58,19 @@ Save your batch configurations for reuse:
|
||||
|
||||

|
||||
|
||||
### Inline Wizard
|
||||
|
||||
Generate new playbooks from within an existing session using the **Inline Wizard**:
|
||||
|
||||
1. Type `/wizard` in any AI tab (or click the Wizard button in the Auto Run panel)
|
||||
2. Have a conversation with the AI about your project goals
|
||||
3. Watch the confidence gauge build as the AI understands your requirements
|
||||
4. At 80%+ confidence, the AI generates detailed Auto Run documents
|
||||
|
||||

|
||||
|
||||
The Inline Wizard creates documents in a unique subfolder under your Auto Run folder, keeping generated playbooks organized. When complete, your tab is renamed to reflect the project and you can immediately start running the generated tasks.
|
||||
|
||||
### Playbook Exchange
|
||||
|
||||
Looking for pre-built playbooks? The [Playbook Exchange](./playbook-exchange) offers community-contributed playbooks for common workflows like security audits, code reviews, and documentation generation. Open it via Quick Actions (`Cmd+K`) or click the Exchange button in the Auto Run panel.
|
||||
|
||||
@@ -65,6 +65,7 @@
|
||||
"usage-dashboard",
|
||||
"autorun-playbooks",
|
||||
"playbook-exchange",
|
||||
"symphony",
|
||||
"git-worktrees",
|
||||
"group-chat",
|
||||
"remote-access",
|
||||
|
||||
@@ -9,6 +9,7 @@ icon: sparkles
|
||||
- 🌳 **[Git Worktrees](./git-worktrees)** - Run AI agents in parallel on isolated branches. Create worktree sub-agents from the git branch menu, each operating in their own directory. Work interactively in the main repo while sub-agents process tasks independently — then create PRs with one click. True parallel development without conflicts.
|
||||
- 🤖 **[Auto Run & Playbooks](./autorun-playbooks)** - File-system-based task runner that batch-processes markdown checklists through AI agents. Create playbooks for repeatable workflows, run in loops, and track progress with full history. Each task gets its own AI session for clean conversation context.
|
||||
- 🏪 **[Playbook Exchange](./playbook-exchange)** - Browse and import community-contributed playbooks directly into your Auto Run folder. Categories, search, and one-click import get you started with proven workflows for security audits, code reviews, documentation, and more.
|
||||
- 🎵 **[Maestro Symphony](./symphony)** - Contribute to open source by donating AI tokens. Browse registered projects, select GitHub issues, and let Maestro clone, process Auto Run docs, and create PRs automatically. Distributed computing for AI-assisted development.
|
||||
- 💬 **[Group Chat](./group-chat)** - Coordinate multiple AI agents in a single conversation. A moderator AI orchestrates discussions, routing questions to the right agents and synthesizing their responses for cross-project questions and architecture discussions.
|
||||
- 🌐 **[Remote Access](./remote-access)** - Built-in web server with QR code access. Monitor and control all your agents from your phone. Supports local network access and remote tunneling via Cloudflare for access from anywhere.
|
||||
- 🔗 **[SSH Remote Execution](./ssh-remote-execution)** - Run AI agents on remote hosts via SSH. Leverage powerful cloud VMs, access tools not installed locally, or work with projects requiring specific environments — all while controlling everything from your local Maestro instance.
|
||||
|
||||
@@ -24,6 +24,8 @@ Press `Cmd+Shift+N` / `Ctrl+Shift+N` to launch the **Onboarding Wizard**, which
|
||||
3. Having a discovery conversation where the AI learns about your project
|
||||
4. Generating an initial Auto Run Playbook with tasks
|
||||
|
||||

|
||||
|
||||
The Wizard creates a fully configured agent with an Auto Run document folder ready to go. Generated documents are saved to an `Initiation/` subfolder within `Auto Run Docs/` to keep them organized separately from documents you create later.
|
||||
|
||||
### Introductory Tour
|
||||
|
||||
|
Before Width: | Height: | Size: 1020 KiB After Width: | Height: | Size: 1.0 MiB |
|
Before Width: | Height: | Size: 433 KiB After Width: | Height: | Size: 392 KiB |
BIN
docs/screenshots/leaderboard.png
Normal file
|
After Width: | Height: | Size: 294 KiB |
|
Before Width: | Height: | Size: 1015 KiB After Width: | Height: | Size: 860 KiB |
BIN
docs/screenshots/symphony-create-agent.png
Normal file
|
After Width: | Height: | Size: 142 KiB |
BIN
docs/screenshots/symphony-details.png
Normal file
|
After Width: | Height: | Size: 508 KiB |
BIN
docs/screenshots/symphony-list.png
Normal file
|
After Width: | Height: | Size: 136 KiB |
BIN
docs/screenshots/wizard-doc-generation.png
Normal file
|
After Width: | Height: | Size: 448 KiB |
BIN
docs/screenshots/wizard-inline.png
Normal file
|
After Width: | Height: | Size: 642 KiB |
159
docs/symphony.md
Normal file
@@ -0,0 +1,159 @@
|
||||
---
|
||||
title: Maestro Symphony
|
||||
description: Contribute to open source projects by donating AI tokens through Maestro Symphony.
|
||||
icon: music
|
||||
---
|
||||
|
||||
Maestro Symphony is a community-driven program that connects Maestro users with open source repository maintainers seeking contributions. Think of it like distributed computing for AI-assisted development — instead of donating CPU cycles, users donate LLM tokens to advance open source.
|
||||
|
||||
## How It Works
|
||||
|
||||
### For Contributors
|
||||
|
||||
1. **Browse available projects** directly within Maestro
|
||||
2. **Select a GitHub issue** that matches your interests
|
||||
3. **Create a dedicated agent session** — choose your AI provider and model
|
||||
4. **Single-click contribution** — Maestro clones the repo, runs Auto Run docs, and creates a PR
|
||||
|
||||

|
||||
|
||||
### For Repository Owners
|
||||
|
||||
1. **Define contribution opportunities** as Auto Run documents in your repository
|
||||
2. **Open a GitHub issue** listing the document paths
|
||||
3. **Add the `runmaestro.ai` label** to make it visible to the Maestro community
|
||||
4. **Receive quality pull requests** generated by AI-assisted workflows
|
||||
|
||||
## Opening Symphony
|
||||
|
||||
| Method | Description |
|
||||
|--------|-------------|
|
||||
| `Cmd+Shift+Y` / `Ctrl+Shift+Y` | Keyboard shortcut |
|
||||
| Quick Actions → "Maestro Symphony" | Command palette (`Cmd+K`) |
|
||||
| Hamburger Menu → "Maestro Symphony" | Menu item |
|
||||
|
||||
## Browsing Projects
|
||||
|
||||
The Symphony modal shows registered open source projects in a tiled grid:
|
||||
|
||||
- **Category filters** — Filter by AI/ML, Developer Tools, etc.
|
||||
- **Search** — Find projects by name or description
|
||||
- **Project tiles** — Click to view available issues
|
||||
|
||||

|
||||
|
||||
Select a repository to see its available GitHub issues. Each issue shows:
|
||||
- Issue title and number
|
||||
- Number of Auto Run documents to process
|
||||
- Document previews (expandable)
|
||||
|
||||
## Starting a Contribution
|
||||
|
||||
Click **Start Symphony** on an available issue to open the agent creation dialog:
|
||||
|
||||

|
||||
|
||||
Configure your contribution:
|
||||
- **AI Provider** — Choose Claude Code, Codex, OpenCode, etc.
|
||||
- **Session Name** — Pre-filled as "Symphony: owner/repo #123"
|
||||
- **Working Directory** — Where the repository will be cloned
|
||||
|
||||
Click **Create Agent** and Maestro will:
|
||||
|
||||
1. Clone the repository to `~/Maestro-Symphony/{owner}-{repo}/`
|
||||
2. Create a new branch for your contribution
|
||||
3. Open a draft PR (claims the issue so others know it's in progress)
|
||||
4. Copy the Auto Run documents to your session
|
||||
5. Begin processing tasks automatically
|
||||
|
||||
## Tracking Contributions
|
||||
|
||||
### Active Tab
|
||||
|
||||
View your in-progress Symphony sessions:
|
||||
- Links to jump to the agent session
|
||||
- Progress indicators for current document
|
||||
- Links to draft PRs
|
||||
- Cancel/abort controls
|
||||
|
||||
### History Tab
|
||||
|
||||
Review completed contributions:
|
||||
- PR links with merge status
|
||||
- Per-contribution statistics
|
||||
- Time and tokens spent
|
||||
|
||||
### Stats Tab
|
||||
|
||||
Track your overall impact:
|
||||
- Total contributions and merged PRs
|
||||
- Tokens donated and estimated cost
|
||||
- Issues resolved and repositories contributed to
|
||||
- Streak tracking and achievement badges
|
||||
|
||||
## Session Integration
|
||||
|
||||
Symphony sessions appear in the Left Bar like any other session:
|
||||
- Named "Symphony: owner/repo #123"
|
||||
- Optionally grouped under a "Symphony" group
|
||||
- Full access to AI Terminal, Command Terminal, and Auto Run controls
|
||||
|
||||
You can interact with Symphony sessions just like regular sessions — pause Auto Run, make manual edits, or switch to the Command Terminal for custom commands.
|
||||
|
||||
## Creating Symphony-Ready Issues
|
||||
|
||||
Repository owners create issues with the `runmaestro.ai` label:
|
||||
|
||||
```markdown
|
||||
## Add unit tests for auth module
|
||||
|
||||
We need comprehensive test coverage for the authentication module.
|
||||
|
||||
### Auto Run Documents
|
||||
- `docs/symphony/auth-tests-1.md`
|
||||
- `docs/symphony/auth-tests-2.md`
|
||||
- `docs/symphony/auth-tests-3.md`
|
||||
|
||||
### Context
|
||||
The auth module is at `src/auth/index.ts`. Tests should use Jest.
|
||||
```
|
||||
|
||||
Each document path should point to an Auto Run-compatible markdown file with task checkboxes.
|
||||
|
||||
## Task Claiming
|
||||
|
||||
When you click **Start Symphony**, Maestro immediately creates a draft PR. This claims the task so other contributors know it's being worked on:
|
||||
|
||||
- **No draft PR** → Task is available
|
||||
- **Draft PR exists** → Task is in progress
|
||||
|
||||
First to create a draft PR wins — this prevents duplicate work.
|
||||
|
||||
## Registering Your Project
|
||||
|
||||
To add your open source project to Symphony:
|
||||
|
||||
1. Fork the [Maestro repository](https://github.com/pedramamini/Maestro)
|
||||
2. Add your project to `symphony-registry.json`
|
||||
3. Submit a pull request
|
||||
|
||||
Your project entry should include:
|
||||
```json
|
||||
{
|
||||
"slug": "owner/repo",
|
||||
"name": "Project Name",
|
||||
"description": "Brief description",
|
||||
"url": "https://github.com/owner/repo",
|
||||
"category": "developer-tools",
|
||||
"tags": ["tag1", "tag2"],
|
||||
"maintainer": {
|
||||
"name": "Your Name",
|
||||
"url": "https://github.com/yourusername"
|
||||
},
|
||||
"isActive": true
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
**Maestro Symphony — Advancing open source, together.**
|
||||
@@ -2024,6 +2024,9 @@ describe('Scroll Position Persistence', () => {
|
||||
const preview = screen.getByTestId('react-markdown').parentElement!;
|
||||
fireEvent.scroll(preview);
|
||||
|
||||
// onStateChange is debounced by 500ms, so we need to advance timers
|
||||
await vi.advanceTimersByTimeAsync(500);
|
||||
|
||||
// onStateChange should be called with scroll position
|
||||
expect(onStateChange).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
@@ -415,6 +415,85 @@ describe('UpdateCheckModal', () => {
|
||||
expect(screen.queryByText('- v1.1.0')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('strips version prefix from release name with pipe separator', async () => {
|
||||
(window.maestro as any).updates.check.mockResolvedValue(
|
||||
createMockUpdateResult({
|
||||
releases: [
|
||||
createMockRelease({
|
||||
tag_name: 'v1.1.0',
|
||||
name: 'v1.1.0 | Doc Graphs, SSH Agents',
|
||||
}),
|
||||
],
|
||||
})
|
||||
);
|
||||
|
||||
render(
|
||||
<UpdateCheckModal
|
||||
theme={createMockTheme()}
|
||||
onClose={vi.fn()}
|
||||
/>
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('v1.1.0')).toBeInTheDocument();
|
||||
// Should show just the description, not the version again
|
||||
expect(screen.getByText('- Doc Graphs, SSH Agents')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Should not show the full original name with duplicate version
|
||||
expect(screen.queryByText('- v1.1.0 | Doc Graphs, SSH Agents')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('strips version prefix from release name with dash separator', async () => {
|
||||
(window.maestro as any).updates.check.mockResolvedValue(
|
||||
createMockUpdateResult({
|
||||
releases: [
|
||||
createMockRelease({
|
||||
tag_name: 'v1.1.0',
|
||||
name: 'v1.1.0 - Major Update',
|
||||
}),
|
||||
],
|
||||
})
|
||||
);
|
||||
|
||||
render(
|
||||
<UpdateCheckModal
|
||||
theme={createMockTheme()}
|
||||
onClose={vi.fn()}
|
||||
/>
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('v1.1.0')).toBeInTheDocument();
|
||||
expect(screen.getByText('- Major Update')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps release name when it does not start with version', async () => {
|
||||
(window.maestro as any).updates.check.mockResolvedValue(
|
||||
createMockUpdateResult({
|
||||
releases: [
|
||||
createMockRelease({
|
||||
tag_name: 'v1.1.0',
|
||||
name: 'Big Feature Release',
|
||||
}),
|
||||
],
|
||||
})
|
||||
);
|
||||
|
||||
render(
|
||||
<UpdateCheckModal
|
||||
theme={createMockTheme()}
|
||||
onClose={vi.fn()}
|
||||
/>
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('v1.1.0')).toBeInTheDocument();
|
||||
expect(screen.getByText('- Big Feature Release')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('shows fallback text when no release body', async () => {
|
||||
(window.maestro as any).updates.check.mockResolvedValue(
|
||||
createMockUpdateResult({
|
||||
|
||||
@@ -1691,8 +1691,10 @@ contextBridge.exposeInMainWorld('maestro', {
|
||||
discordUsername?: string;
|
||||
badgeLevel: number;
|
||||
badgeName: string;
|
||||
cumulativeTimeMs: number;
|
||||
totalRuns: number;
|
||||
// Stats fields are optional for profile-only submissions (multi-device safe)
|
||||
// When omitted, server keeps existing values instead of overwriting
|
||||
cumulativeTimeMs?: number;
|
||||
totalRuns?: number;
|
||||
longestRunMs?: number;
|
||||
longestRunDate?: string;
|
||||
currentRunMs?: number;
|
||||
@@ -2901,8 +2903,9 @@ export interface MaestroAPI {
|
||||
discordUsername?: string;
|
||||
badgeLevel: number;
|
||||
badgeName: string;
|
||||
cumulativeTimeMs: number;
|
||||
totalRuns: number;
|
||||
// Stats fields are optional for profile-only submissions (multi-device safe)
|
||||
cumulativeTimeMs?: number;
|
||||
totalRuns?: number;
|
||||
longestRunMs?: number;
|
||||
longestRunDate?: string;
|
||||
currentRunMs?: number;
|
||||
@@ -2915,6 +2918,10 @@ export interface MaestroAPI {
|
||||
keyboardCoveragePercent?: number;
|
||||
keyboardKeysUnlocked?: number;
|
||||
keyboardTotalKeys?: number;
|
||||
// Delta mode for multi-device aggregation
|
||||
deltaMs?: number;
|
||||
deltaRuns?: number;
|
||||
clientTotalTimeMs?: number;
|
||||
}) => Promise<{
|
||||
success: boolean;
|
||||
message: string;
|
||||
|
||||
@@ -4309,6 +4309,11 @@ You are taking over this conversation. Based on the context above, provide a bri
|
||||
if (!leaderboardRegistration.authToken) {
|
||||
console.warn('Leaderboard submission skipped: no auth token');
|
||||
} else {
|
||||
// Auto Run completion submission: Use delta mode for multi-device aggregation
|
||||
// API behavior:
|
||||
// - If deltaMs > 0 is present: Server adds deltaMs to running total (delta mode)
|
||||
// - If only cumulativeTimeMs (no deltaMs): Server replaces value (legacy mode)
|
||||
// We send deltaMs to trigger delta mode, ensuring proper aggregation across devices.
|
||||
window.maestro.leaderboard.submit({
|
||||
email: leaderboardRegistration.email,
|
||||
displayName: leaderboardRegistration.displayName,
|
||||
@@ -4317,6 +4322,7 @@ You are taking over this conversation. Based on the context above, provide a bri
|
||||
linkedinHandle: leaderboardRegistration.linkedinHandle,
|
||||
badgeLevel: updatedBadgeLevel,
|
||||
badgeName: updatedBadgeName,
|
||||
// Legacy fields (server ignores when deltaMs is present)
|
||||
cumulativeTimeMs: updatedCumulativeTimeMs,
|
||||
totalRuns: updatedTotalRuns,
|
||||
longestRunMs: updatedLongestRunMs,
|
||||
@@ -4324,10 +4330,10 @@ You are taking over this conversation. Based on the context above, provide a bri
|
||||
currentRunMs: info.elapsedTimeMs,
|
||||
theme: activeThemeId,
|
||||
authToken: leaderboardRegistration.authToken,
|
||||
// Delta mode for multi-device aggregation
|
||||
// Delta mode: Server adds these to running totals
|
||||
deltaMs: info.elapsedTimeMs,
|
||||
deltaRuns: 1,
|
||||
// Client's total time for multi-device discrepancy detection
|
||||
// Client's local total for discrepancy detection
|
||||
clientTotalTimeMs: updatedCumulativeTimeMs,
|
||||
}).then(result => {
|
||||
if (result.success) {
|
||||
|
||||
@@ -104,8 +104,56 @@ export interface AutoRunHandle {
|
||||
getCompletedTaskCount: () => number;
|
||||
}
|
||||
|
||||
// Helper to compute initial image state synchronously from cache
|
||||
// This prevents flickering when ReactMarkdown rebuilds the component tree
|
||||
function getInitialImageState(src: string | undefined, folderPath: string | null) {
|
||||
if (!src) {
|
||||
return { dataUrl: null, loading: false, filename: null };
|
||||
}
|
||||
|
||||
const decodedSrc = decodeURIComponent(src);
|
||||
|
||||
// Check cache for relative paths
|
||||
if (decodedSrc.startsWith('images/') && folderPath) {
|
||||
const cacheKey = `${folderPath}:${decodedSrc}`;
|
||||
if (imageCache.has(cacheKey)) {
|
||||
return {
|
||||
dataUrl: imageCache.get(cacheKey)!,
|
||||
loading: false,
|
||||
filename: decodedSrc.split('/').pop() || decodedSrc,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Data URLs are ready immediately
|
||||
if (src.startsWith('data:')) {
|
||||
return { dataUrl: src, loading: false, filename: null };
|
||||
}
|
||||
|
||||
// HTTP URLs are ready immediately (browser handles loading)
|
||||
if (src.startsWith('http://') || src.startsWith('https://')) {
|
||||
return { dataUrl: src, loading: false, filename: null };
|
||||
}
|
||||
|
||||
// Check cache for other relative paths
|
||||
if (folderPath) {
|
||||
const cacheKey = `${folderPath}:${src}`;
|
||||
if (imageCache.has(cacheKey)) {
|
||||
return {
|
||||
dataUrl: imageCache.get(cacheKey)!,
|
||||
loading: false,
|
||||
filename: src.split('/').pop() || null,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Need to load - return loading state
|
||||
return { dataUrl: null, loading: true, filename: src.split('/').pop() || null };
|
||||
}
|
||||
|
||||
// Custom image component that loads images from the Auto Run folder or external URLs
|
||||
function AttachmentImage({
|
||||
// Memoized to prevent re-renders and image reloading when parent updates
|
||||
const AttachmentImage = memo(function AttachmentImage({
|
||||
src,
|
||||
alt,
|
||||
folderPath,
|
||||
@@ -120,12 +168,30 @@ function AttachmentImage({
|
||||
theme: any;
|
||||
onImageClick?: (filename: string) => void;
|
||||
}) {
|
||||
const [dataUrl, setDataUrl] = useState<string | null>(null);
|
||||
// Compute initial state synchronously from cache to prevent flicker
|
||||
const initialState = useMemo(
|
||||
() => getInitialImageState(src, folderPath),
|
||||
[src, folderPath]
|
||||
);
|
||||
|
||||
const [dataUrl, setDataUrl] = useState<string | null>(initialState.dataUrl);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [filename, setFilename] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(initialState.loading);
|
||||
const [filename, setFilename] = useState<string | null>(initialState.filename);
|
||||
|
||||
// Use ref for onImageClick to avoid re-running effect when callback changes
|
||||
const onImageClickRef = useRef(onImageClick);
|
||||
onImageClickRef.current = onImageClick;
|
||||
|
||||
useEffect(() => {
|
||||
// If we already have data from cache (initialState), skip loading
|
||||
if (initialState.dataUrl) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Track whether this effect is stale (component unmounted or src changed)
|
||||
let isStale = false;
|
||||
|
||||
if (!src) {
|
||||
setLoading(false);
|
||||
return;
|
||||
@@ -140,7 +206,7 @@ function AttachmentImage({
|
||||
setFilename(fname);
|
||||
const cacheKey = `${folderPath}:${decodedSrc}`;
|
||||
|
||||
// Check cache first
|
||||
// Double-check cache (in case it was populated after initial render)
|
||||
if (imageCache.has(cacheKey)) {
|
||||
setDataUrl(imageCache.get(cacheKey)!);
|
||||
setLoading(false);
|
||||
@@ -151,6 +217,7 @@ function AttachmentImage({
|
||||
const absolutePath = `${folderPath}/${decodedSrc}`;
|
||||
window.maestro.fs.readFile(absolutePath, sshRemoteId)
|
||||
.then((result) => {
|
||||
if (isStale) return;
|
||||
if (result.startsWith('data:')) {
|
||||
imageCache.set(cacheKey, result);
|
||||
setDataUrl(result);
|
||||
@@ -160,24 +227,16 @@ function AttachmentImage({
|
||||
setLoading(false);
|
||||
})
|
||||
.catch((err) => {
|
||||
if (isStale) return;
|
||||
setError(`Failed to load image: ${err.message || 'Unknown error'}`);
|
||||
setLoading(false);
|
||||
});
|
||||
} else if (src.startsWith('data:')) {
|
||||
// Already a data URL
|
||||
setDataUrl(src);
|
||||
setFilename(null);
|
||||
setLoading(false);
|
||||
} else if (src.startsWith('http://') || src.startsWith('https://')) {
|
||||
// External URL - just use it directly
|
||||
setDataUrl(src);
|
||||
setFilename(null);
|
||||
setLoading(false);
|
||||
} else if (src.startsWith('/')) {
|
||||
// Absolute file path - load via IPC
|
||||
setFilename(src.split('/').pop() || null);
|
||||
window.maestro.fs.readFile(src, sshRemoteId)
|
||||
.then((result) => {
|
||||
if (isStale) return;
|
||||
if (result.startsWith('data:')) {
|
||||
setDataUrl(result);
|
||||
} else {
|
||||
@@ -186,16 +245,30 @@ function AttachmentImage({
|
||||
setLoading(false);
|
||||
})
|
||||
.catch((err) => {
|
||||
if (isStale) return;
|
||||
setError(`Failed to load image: ${err.message || 'Unknown error'}`);
|
||||
setLoading(false);
|
||||
});
|
||||
} else {
|
||||
// Other relative path - try to load as file from folderPath if available
|
||||
setFilename(src.split('/').pop() || null);
|
||||
const cacheKey = folderPath ? `${folderPath}:${src}` : src;
|
||||
|
||||
// Double-check cache
|
||||
if (imageCache.has(cacheKey)) {
|
||||
setDataUrl(imageCache.get(cacheKey)!);
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const pathToLoad = folderPath ? `${folderPath}/${src}` : src;
|
||||
window.maestro.fs.readFile(pathToLoad, sshRemoteId)
|
||||
.then((result) => {
|
||||
if (isStale) return;
|
||||
if (result.startsWith('data:')) {
|
||||
if (folderPath) {
|
||||
imageCache.set(cacheKey, result);
|
||||
}
|
||||
setDataUrl(result);
|
||||
} else {
|
||||
setError('Invalid image data');
|
||||
@@ -203,11 +276,16 @@ function AttachmentImage({
|
||||
setLoading(false);
|
||||
})
|
||||
.catch((err) => {
|
||||
if (isStale) return;
|
||||
setError(`Failed to load image: ${err.message || 'Unknown error'}`);
|
||||
setLoading(false);
|
||||
});
|
||||
}
|
||||
}, [src, folderPath, sshRemoteId]);
|
||||
|
||||
return () => {
|
||||
isStale = true;
|
||||
};
|
||||
}, [src, folderPath, sshRemoteId, initialState.dataUrl]);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
@@ -243,7 +321,7 @@ function AttachmentImage({
|
||||
return (
|
||||
<span
|
||||
className="inline-block align-middle mx-1 my-1 cursor-pointer group relative"
|
||||
onClick={() => onImageClick?.(decodedSrcForClick)}
|
||||
onClick={() => onImageClickRef.current?.(decodedSrcForClick)}
|
||||
title={filename ? `Click to enlarge: ${filename}` : 'Click to enlarge'}
|
||||
>
|
||||
<img
|
||||
@@ -266,7 +344,7 @@ function AttachmentImage({
|
||||
</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
})
|
||||
|
||||
// Image preview thumbnail for staged images in edit mode
|
||||
function ImagePreview({
|
||||
@@ -794,20 +872,39 @@ const AutoRunInner = forwardRef<AutoRunHandle, AutoRunProps>(function AutoRunInn
|
||||
}
|
||||
};
|
||||
|
||||
const handlePreviewScroll = () => {
|
||||
// Debounced preview scroll handler to avoid triggering re-renders on every scroll event
|
||||
// We only save scroll position to ref immediately (for local use), but delay parent notification
|
||||
const previewScrollDebounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const handlePreviewScroll = useCallback(() => {
|
||||
if (previewRef.current) {
|
||||
// Save to ref for persistence across re-renders
|
||||
// Save to ref immediately for local persistence
|
||||
previewScrollPosRef.current = previewRef.current.scrollTop;
|
||||
if (onStateChange) {
|
||||
onStateChange({
|
||||
mode,
|
||||
cursorPosition: textareaRef.current?.selectionStart || 0,
|
||||
editScrollPos: textareaRef.current?.scrollTop || 0,
|
||||
previewScrollPos: previewRef.current.scrollTop
|
||||
});
|
||||
|
||||
// Debounce the parent state update to avoid cascading re-renders
|
||||
if (previewScrollDebounceRef.current) {
|
||||
clearTimeout(previewScrollDebounceRef.current);
|
||||
}
|
||||
previewScrollDebounceRef.current = setTimeout(() => {
|
||||
if (onStateChange && previewRef.current) {
|
||||
onStateChange({
|
||||
mode,
|
||||
cursorPosition: textareaRef.current?.selectionStart || 0,
|
||||
editScrollPos: textareaRef.current?.scrollTop || 0,
|
||||
previewScrollPos: previewRef.current.scrollTop
|
||||
});
|
||||
}
|
||||
}, 500); // Only notify parent after 500ms of no scrolling
|
||||
}
|
||||
};
|
||||
}, [mode, onStateChange]);
|
||||
|
||||
// Cleanup debounce timer on unmount
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (previewScrollDebounceRef.current) {
|
||||
clearTimeout(previewScrollDebounceRef.current);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Handle refresh for empty state with animation
|
||||
const handleEmptyStateRefresh = useCallback(async () => {
|
||||
|
||||
@@ -5,6 +5,7 @@ import { ChevronRight, ChevronDown, ChevronUp, Folder, RefreshCw, Check, Eye, Ey
|
||||
import type { Session, Theme, FocusArea } from '../types';
|
||||
import type { FileNode } from '../types/fileTree';
|
||||
import type { FileTreeChanges } from '../utils/fileExplorer';
|
||||
import { removeNodeFromTree, renameNodeInTree, findNodeInTree, countNodesInTree } from '../utils/fileExplorer';
|
||||
import { getFileIcon } from '../utils/theme';
|
||||
import { useLayerStack } from '../contexts/LayerStackContext';
|
||||
import { MODAL_PRIORITIES } from '../constants/modalPriorities';
|
||||
@@ -544,14 +545,38 @@ function FileExplorerPanelInner(props: FileExplorerPanelProps) {
|
||||
const parentDir = renameModal.absolutePath.substring(0, renameModal.absolutePath.lastIndexOf('/'));
|
||||
const newPath = `${parentDir}/${newName}`;
|
||||
await window.maestro.fs.rename(renameModal.absolutePath, newPath, sshRemoteId);
|
||||
|
||||
// Update tree locally instead of full refresh
|
||||
const newTree = renameNodeInTree(session.fileTree || [], renameModal.path, newName);
|
||||
|
||||
// Calculate the new path for expanded folder updates
|
||||
const oldPath = renameModal.path;
|
||||
const pathParts = oldPath.split('/');
|
||||
pathParts[pathParts.length - 1] = newName;
|
||||
const newRelativePath = pathParts.join('/');
|
||||
|
||||
setSessions(prev => prev.map(s => {
|
||||
if (s.id !== session.id) return s;
|
||||
return {
|
||||
...s,
|
||||
fileTree: newTree,
|
||||
// Update expanded folder paths if renamed item was a folder
|
||||
fileExplorerExpanded: renameModal.node.type === 'folder'
|
||||
? (s.fileExplorerExpanded || []).map(p => {
|
||||
if (p === oldPath) return newRelativePath;
|
||||
if (p.startsWith(oldPath + '/')) return newRelativePath + p.slice(oldPath.length);
|
||||
return p;
|
||||
})
|
||||
: s.fileExplorerExpanded
|
||||
};
|
||||
}));
|
||||
|
||||
setRenameModal(null);
|
||||
// Refresh file tree
|
||||
refreshFileTree(session.id);
|
||||
onShowFlash?.(`Renamed to "${newName}"`);
|
||||
} catch (error) {
|
||||
setRenameError(error instanceof Error ? error.message : 'Rename failed');
|
||||
}
|
||||
}, [renameModal, renameValue, refreshFileTree, session.id, onShowFlash, sshRemoteId]);
|
||||
}, [renameModal, renameValue, session.id, session.fileTree, onShowFlash, sshRemoteId, setSessions]);
|
||||
|
||||
// Open delete confirmation modal
|
||||
const handleOpenDelete = useCallback(async () => {
|
||||
@@ -585,16 +610,52 @@ function FileExplorerPanelInner(props: FileExplorerPanelProps) {
|
||||
setIsDeleting(true);
|
||||
try {
|
||||
await window.maestro.fs.delete(deleteModal.absolutePath, { sshRemoteId });
|
||||
|
||||
// Get the node being deleted to count its contents for stats update
|
||||
const deletedNode = findNodeInTree(session.fileTree || [], deleteModal.path);
|
||||
let deletedFileCount = 0;
|
||||
let deletedFolderCount = 0;
|
||||
|
||||
if (deletedNode) {
|
||||
if (deletedNode.type === 'folder') {
|
||||
deletedFolderCount = 1;
|
||||
if (deletedNode.children) {
|
||||
const childCounts = countNodesInTree(deletedNode.children);
|
||||
deletedFileCount = childCounts.fileCount;
|
||||
deletedFolderCount += childCounts.folderCount;
|
||||
}
|
||||
} else {
|
||||
deletedFileCount = 1;
|
||||
}
|
||||
}
|
||||
|
||||
// Update tree locally instead of full refresh
|
||||
const newTree = removeNodeFromTree(session.fileTree || [], deleteModal.path);
|
||||
setSessions(prev => prev.map(s => {
|
||||
if (s.id !== session.id) return s;
|
||||
return {
|
||||
...s,
|
||||
fileTree: newTree,
|
||||
fileTreeStats: s.fileTreeStats ? {
|
||||
...s.fileTreeStats,
|
||||
fileCount: Math.max(0, s.fileTreeStats.fileCount - deletedFileCount),
|
||||
folderCount: Math.max(0, s.fileTreeStats.folderCount - deletedFolderCount),
|
||||
} : undefined,
|
||||
// Also remove from expanded folders if it was a folder
|
||||
fileExplorerExpanded: deleteModal.node.type === 'folder'
|
||||
? (s.fileExplorerExpanded || []).filter(p => !p.startsWith(deleteModal.path))
|
||||
: s.fileExplorerExpanded
|
||||
};
|
||||
}));
|
||||
|
||||
setDeleteModal(null);
|
||||
// Refresh file tree
|
||||
refreshFileTree(session.id);
|
||||
onShowFlash?.(`Deleted "${deleteModal.node.name}"`);
|
||||
} catch (error) {
|
||||
onShowFlash?.(`Delete failed: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
||||
} finally {
|
||||
setIsDeleting(false);
|
||||
}
|
||||
}, [deleteModal, refreshFileTree, session.id, onShowFlash, sshRemoteId]);
|
||||
}, [deleteModal, session.id, session.fileTree, onShowFlash, sshRemoteId, setSessions]);
|
||||
|
||||
// Close context menu on Escape key
|
||||
useEffect(() => {
|
||||
|
||||
@@ -219,6 +219,17 @@ export function LeaderboardRegistrationModal({
|
||||
longestRunDate = new Date(autoRunStats.longestRunTimestamp).toISOString().split('T')[0];
|
||||
}
|
||||
|
||||
// IMPORTANT: For multi-device support, we use delta mode for stats updates.
|
||||
// Profile-only submissions (when user has no new stats to report) should omit
|
||||
// cumulative fields to avoid overwriting server totals from other devices.
|
||||
// Stats are updated via delta mode only when Auto Runs complete in App.tsx.
|
||||
//
|
||||
// API behavior:
|
||||
// - If deltaMs > 0 is present: Delta mode - adds to server totals
|
||||
// - If only cumulativeTimeMs (no deltaMs): Legacy mode - REPLACES server value
|
||||
//
|
||||
// We send local cumulative as clientTotalTimeMs for discrepancy detection,
|
||||
// but NOT as the primary cumulativeTimeMs to avoid overwrites.
|
||||
const result = await window.maestro.leaderboard.submit({
|
||||
email: email.trim(),
|
||||
displayName: displayName.trim(),
|
||||
@@ -228,8 +239,10 @@ export function LeaderboardRegistrationModal({
|
||||
discordUsername: discordUsername.trim() || undefined,
|
||||
badgeLevel,
|
||||
badgeName,
|
||||
cumulativeTimeMs: autoRunStats.cumulativeTimeMs,
|
||||
totalRuns: autoRunStats.totalRuns,
|
||||
// Don't send cumulativeTimeMs/totalRuns in legacy mode - they would overwrite server!
|
||||
// For new registrations, server creates with 0 values.
|
||||
// For existing users, server keeps its current totals.
|
||||
// Only send longestRun if it might be a new record (server can compare).
|
||||
longestRunMs: autoRunStats.longestRunMs || undefined,
|
||||
longestRunDate,
|
||||
theme: theme.id,
|
||||
@@ -241,7 +254,7 @@ export function LeaderboardRegistrationModal({
|
||||
keyboardCoveragePercent,
|
||||
keyboardKeysUnlocked,
|
||||
keyboardTotalKeys,
|
||||
// Client's total time for multi-device discrepancy detection
|
||||
// Client's local total for discrepancy detection (server won't use this to overwrite)
|
||||
clientTotalTimeMs: autoRunStats.cumulativeTimeMs,
|
||||
});
|
||||
|
||||
@@ -269,7 +282,8 @@ export function LeaderboardRegistrationModal({
|
||||
startPolling(clientToken);
|
||||
} else {
|
||||
setSubmitState('success');
|
||||
setSuccessMessage('Your stats have been submitted! Your entry is now queued for manual approval.');
|
||||
// Profile submitted - stats sync via delta mode from Auto Runs or Pull Down
|
||||
setSuccessMessage('Profile submitted! Stats are synced via Auto Runs. Use "Pull Down" to sync from other devices.');
|
||||
}
|
||||
} else if (result.authTokenRequired) {
|
||||
// Email is confirmed but auth token is missing/invalid - try to recover it automatically
|
||||
@@ -302,6 +316,7 @@ export function LeaderboardRegistrationModal({
|
||||
longestRunDate = new Date(autoRunStats.longestRunTimestamp).toISOString().split('T')[0];
|
||||
}
|
||||
|
||||
// Retry submission with recovered token - same multi-device safe approach
|
||||
const retryResult = await window.maestro.leaderboard.submit({
|
||||
email: email.trim(),
|
||||
displayName: displayName.trim(),
|
||||
@@ -311,8 +326,7 @@ export function LeaderboardRegistrationModal({
|
||||
discordUsername: discordUsername.trim() || undefined,
|
||||
badgeLevel,
|
||||
badgeName,
|
||||
cumulativeTimeMs: autoRunStats.cumulativeTimeMs,
|
||||
totalRuns: autoRunStats.totalRuns,
|
||||
// Don't send cumulativeTimeMs/totalRuns - avoid overwriting server totals
|
||||
longestRunMs: autoRunStats.longestRunMs || undefined,
|
||||
longestRunDate,
|
||||
theme: theme.id,
|
||||
@@ -324,7 +338,7 @@ export function LeaderboardRegistrationModal({
|
||||
keyboardCoveragePercent,
|
||||
keyboardKeysUnlocked,
|
||||
keyboardTotalKeys,
|
||||
// Client's total time for multi-device discrepancy detection
|
||||
// Client's local total for discrepancy detection
|
||||
clientTotalTimeMs: autoRunStats.cumulativeTimeMs,
|
||||
});
|
||||
|
||||
@@ -386,6 +400,7 @@ export function LeaderboardRegistrationModal({
|
||||
longestRunDate = new Date(autoRunStats.longestRunTimestamp).toISOString().split('T')[0];
|
||||
}
|
||||
|
||||
// Manual token submission - same multi-device safe approach
|
||||
const result = await window.maestro.leaderboard.submit({
|
||||
email: email.trim(),
|
||||
displayName: displayName.trim(),
|
||||
@@ -395,8 +410,7 @@ export function LeaderboardRegistrationModal({
|
||||
discordUsername: discordUsername.trim() || undefined,
|
||||
badgeLevel,
|
||||
badgeName,
|
||||
cumulativeTimeMs: autoRunStats.cumulativeTimeMs,
|
||||
totalRuns: autoRunStats.totalRuns,
|
||||
// Don't send cumulativeTimeMs/totalRuns - avoid overwriting server totals
|
||||
longestRunMs: autoRunStats.longestRunMs || undefined,
|
||||
longestRunDate,
|
||||
theme: theme.id,
|
||||
@@ -408,13 +422,13 @@ export function LeaderboardRegistrationModal({
|
||||
keyboardCoveragePercent,
|
||||
keyboardKeysUnlocked,
|
||||
keyboardTotalKeys,
|
||||
// Client's total time for multi-device discrepancy detection
|
||||
// Client's local total for discrepancy detection
|
||||
clientTotalTimeMs: autoRunStats.cumulativeTimeMs,
|
||||
});
|
||||
|
||||
if (result.success) {
|
||||
setSubmitState('success');
|
||||
setSuccessMessage('Your stats have been submitted! Your entry is now queued for manual approval.');
|
||||
setSuccessMessage('Your profile has been updated! Use "Pull Down" to sync stats from the server.');
|
||||
} else {
|
||||
setSubmitState('error');
|
||||
setErrorMessage(result.error || result.message || 'Submission failed. Please check your auth token.');
|
||||
|
||||
@@ -15,6 +15,9 @@ import { remarkFrontmatterTable } from '../utils/remarkFrontmatterTable';
|
||||
// LocalImage - Loads local images via IPC
|
||||
// ============================================================================
|
||||
|
||||
// Module-level cache for local images to prevent flicker on re-render
|
||||
const localImageCache = new Map<string, string>();
|
||||
|
||||
interface LocalImageProps {
|
||||
src?: string;
|
||||
alt?: string;
|
||||
@@ -23,44 +26,76 @@ interface LocalImageProps {
|
||||
sshRemoteId?: string; // SSH remote ID for remote file operations
|
||||
}
|
||||
|
||||
// Helper to compute initial image state synchronously from cache
|
||||
// This prevents flickering when ReactMarkdown rebuilds the component tree
|
||||
function getLocalImageInitialState(src: string | undefined) {
|
||||
if (!src) {
|
||||
return { dataUrl: null, loading: false };
|
||||
}
|
||||
|
||||
// Data URLs are ready immediately
|
||||
if (src.startsWith('data:')) {
|
||||
return { dataUrl: src, loading: false };
|
||||
}
|
||||
|
||||
// HTTP URLs are ready immediately (browser handles loading)
|
||||
if (src.startsWith('http://') || src.startsWith('https://')) {
|
||||
return { dataUrl: src, loading: false };
|
||||
}
|
||||
|
||||
// Check cache for file paths
|
||||
let filePath = src;
|
||||
if (src.startsWith('file://')) {
|
||||
filePath = decodeURIComponent(src.replace('file://', ''));
|
||||
}
|
||||
|
||||
if (localImageCache.has(filePath)) {
|
||||
return { dataUrl: localImageCache.get(filePath)!, loading: false };
|
||||
}
|
||||
|
||||
// Need to load
|
||||
return { dataUrl: null, loading: true };
|
||||
}
|
||||
|
||||
const LocalImage = memo(({ src, alt, theme, width, sshRemoteId }: LocalImageProps) => {
|
||||
const [dataUrl, setDataUrl] = useState<string | null>(null);
|
||||
// Compute initial state synchronously from cache to prevent flicker
|
||||
const initialState = useMemo(() => getLocalImageInitialState(src), [src]);
|
||||
|
||||
const [dataUrl, setDataUrl] = useState<string | null>(initialState.dataUrl);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [loading, setLoading] = useState(initialState.loading);
|
||||
|
||||
useEffect(() => {
|
||||
setError(null);
|
||||
setDataUrl(null);
|
||||
// If we already have data from cache, skip loading
|
||||
if (initialState.dataUrl) {
|
||||
return;
|
||||
}
|
||||
|
||||
let isStale = false;
|
||||
|
||||
if (!src) {
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// If it's already a data URL, use it directly
|
||||
if (src.startsWith('data:')) {
|
||||
setDataUrl(src);
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// If it's an HTTP(S) URL, use it directly (browser will handle)
|
||||
if (src.startsWith('http://') || src.startsWith('https://')) {
|
||||
setDataUrl(src);
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// For file:// URLs, extract the path and load via IPC
|
||||
let filePath = src;
|
||||
if (src.startsWith('file://')) {
|
||||
filePath = decodeURIComponent(src.replace('file://', ''));
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
// Double-check cache
|
||||
if (localImageCache.has(filePath)) {
|
||||
setDataUrl(localImageCache.get(filePath)!);
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
window.maestro.fs.readFile(filePath, sshRemoteId)
|
||||
.then((result) => {
|
||||
if (isStale) return;
|
||||
if (result.startsWith('data:')) {
|
||||
localImageCache.set(filePath, result);
|
||||
setDataUrl(result);
|
||||
} else {
|
||||
setError('Invalid image data');
|
||||
@@ -68,10 +103,15 @@ const LocalImage = memo(({ src, alt, theme, width, sshRemoteId }: LocalImageProp
|
||||
setLoading(false);
|
||||
})
|
||||
.catch((err) => {
|
||||
if (isStale) return;
|
||||
setError(`Failed to load image: ${err.message || 'Unknown error'}`);
|
||||
setLoading(false);
|
||||
});
|
||||
}, [src, sshRemoteId]);
|
||||
|
||||
return () => {
|
||||
isStale = true;
|
||||
};
|
||||
}, [src, sshRemoteId, initialState.dataUrl]);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
|
||||
@@ -261,11 +261,39 @@ export function UpdateCheckModal({ theme, onClose }: UpdateCheckModalProps) {
|
||||
<span className="font-mono font-bold text-sm" style={{ color: theme.colors.accent }}>
|
||||
{release.tag_name}
|
||||
</span>
|
||||
{release.name && release.name !== release.tag_name && (
|
||||
<span className="text-xs" style={{ color: theme.colors.textDim }}>
|
||||
- {release.name}
|
||||
</span>
|
||||
)}
|
||||
{(() => {
|
||||
// Strip version prefix if name starts with it (e.g., "v0.14.2 | Description" -> "Description")
|
||||
if (!release.name || release.name === release.tag_name) return null;
|
||||
const name = release.name;
|
||||
const tag = release.tag_name;
|
||||
let displayName: string | null = name;
|
||||
|
||||
// Check for patterns like "v0.14.2 | Description" or "v0.14.2 - Description"
|
||||
const pipeIndex = name.indexOf('|');
|
||||
const dashIndex = name.indexOf(' - ');
|
||||
|
||||
if (pipeIndex !== -1 && name.substring(0, pipeIndex).trim().toLowerCase() === tag.toLowerCase()) {
|
||||
displayName = name.substring(pipeIndex + 1).trim();
|
||||
} else if (dashIndex !== -1 && name.substring(0, dashIndex).trim().toLowerCase() === tag.toLowerCase()) {
|
||||
displayName = name.substring(dashIndex + 3).trim();
|
||||
} else if (name.toLowerCase().startsWith(tag.toLowerCase())) {
|
||||
// If name just starts with the tag, strip it
|
||||
const remainder = name.substring(tag.length).trim();
|
||||
// Remove leading separator if present
|
||||
if (remainder.startsWith('|') || remainder.startsWith('-')) {
|
||||
displayName = remainder.substring(1).trim();
|
||||
} else {
|
||||
displayName = remainder || null;
|
||||
}
|
||||
}
|
||||
|
||||
if (!displayName) return null;
|
||||
return (
|
||||
<span className="text-xs" style={{ color: theme.colors.textDim }}>
|
||||
- {displayName}
|
||||
</span>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
<span className="text-xs" style={{ color: theme.colors.textDim }}>
|
||||
{formatDate(release.published_at)}
|
||||
@@ -273,7 +301,7 @@ export function UpdateCheckModal({ theme, onClose }: UpdateCheckModalProps) {
|
||||
</button>
|
||||
{expandedReleases.has(release.tag_name) && (
|
||||
<div
|
||||
className="p-3 border-t text-xs prose prose-sm prose-invert max-w-none"
|
||||
className="py-3 px-5 border-t text-xs prose prose-sm prose-invert max-w-none"
|
||||
style={{ borderColor: theme.colors.border, color: theme.colors.textDim }}
|
||||
>
|
||||
<ReactMarkdown
|
||||
|
||||
6
src/renderer/global.d.ts
vendored
@@ -1350,8 +1350,10 @@ interface MaestroAPI {
|
||||
discordUsername?: string;
|
||||
badgeLevel: number;
|
||||
badgeName: string;
|
||||
cumulativeTimeMs: number;
|
||||
totalRuns: number;
|
||||
// Stats fields are optional for profile-only submissions (multi-device safe)
|
||||
// When omitted, server keeps existing values instead of overwriting
|
||||
cumulativeTimeMs?: number;
|
||||
totalRuns?: number;
|
||||
longestRunMs?: number;
|
||||
longestRunDate?: string;
|
||||
currentRunMs?: number;
|
||||
|
||||
@@ -279,3 +279,152 @@ export function compareFileTrees(
|
||||
removedFolders
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a node from the file tree at the given path.
|
||||
* Returns a new tree with the node removed.
|
||||
* @param tree - The file tree to modify
|
||||
* @param relativePath - Path relative to tree root (e.g., "folder/file.txt")
|
||||
* @returns New tree with the node removed, or original tree if path not found
|
||||
*/
|
||||
export function removeNodeFromTree(
|
||||
tree: FileTreeNode[],
|
||||
relativePath: string
|
||||
): FileTreeNode[] {
|
||||
const parts = relativePath.split('/').filter(Boolean);
|
||||
if (parts.length === 0) return tree;
|
||||
|
||||
const targetName = parts[parts.length - 1];
|
||||
const parentParts = parts.slice(0, -1);
|
||||
|
||||
// If at root level, filter out the target
|
||||
if (parentParts.length === 0) {
|
||||
return tree.filter(node => node.name !== targetName);
|
||||
}
|
||||
|
||||
// Navigate to parent and remove from there
|
||||
return tree.map(node => {
|
||||
if (node.name === parentParts[0]) {
|
||||
if (parentParts.length === 1) {
|
||||
// This node is the parent - remove target from children
|
||||
return {
|
||||
...node,
|
||||
children: node.children?.filter(child => child.name !== targetName)
|
||||
};
|
||||
}
|
||||
// Keep navigating
|
||||
return {
|
||||
...node,
|
||||
children: node.children ? removeNodeFromTree(node.children, parentParts.slice(1).concat(targetName).join('/')) : undefined
|
||||
};
|
||||
}
|
||||
return node;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Rename a node in the file tree at the given path.
|
||||
* Returns a new tree with the node renamed and re-sorted.
|
||||
* @param tree - The file tree to modify
|
||||
* @param relativePath - Path relative to tree root (e.g., "folder/oldname.txt")
|
||||
* @param newName - The new name for the node
|
||||
* @returns New tree with the node renamed, or original tree if path not found
|
||||
*/
|
||||
export function renameNodeInTree(
|
||||
tree: FileTreeNode[],
|
||||
relativePath: string,
|
||||
newName: string
|
||||
): FileTreeNode[] {
|
||||
const parts = relativePath.split('/').filter(Boolean);
|
||||
if (parts.length === 0) return tree;
|
||||
|
||||
const targetName = parts[parts.length - 1];
|
||||
const parentParts = parts.slice(0, -1);
|
||||
|
||||
const sortNodes = (nodes: FileTreeNode[]): FileTreeNode[] => {
|
||||
return [...nodes].sort((a, b) => {
|
||||
if (a.type === 'folder' && b.type !== 'folder') return -1;
|
||||
if (a.type !== 'folder' && b.type === 'folder') return 1;
|
||||
return a.name.localeCompare(b.name);
|
||||
});
|
||||
};
|
||||
|
||||
// If at root level, rename and re-sort
|
||||
if (parentParts.length === 0) {
|
||||
const renamed = tree.map(node =>
|
||||
node.name === targetName ? { ...node, name: newName } : node
|
||||
);
|
||||
return sortNodes(renamed);
|
||||
}
|
||||
|
||||
// Navigate to parent and rename there
|
||||
return tree.map(node => {
|
||||
if (node.name === parentParts[0]) {
|
||||
if (parentParts.length === 1) {
|
||||
// This node is the parent - rename target in children
|
||||
const renamed = node.children?.map(child =>
|
||||
child.name === targetName ? { ...child, name: newName } : child
|
||||
);
|
||||
return {
|
||||
...node,
|
||||
children: renamed ? sortNodes(renamed) : undefined
|
||||
};
|
||||
}
|
||||
// Keep navigating
|
||||
return {
|
||||
...node,
|
||||
children: node.children ? renameNodeInTree(node.children, parentParts.slice(1).concat(targetName).join('/'), newName) : undefined
|
||||
};
|
||||
}
|
||||
return node;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Count files and folders in a tree node recursively.
|
||||
* Used to update stats when a node is removed.
|
||||
*/
|
||||
export function countNodesInTree(nodes: FileTreeNode[]): { fileCount: number; folderCount: number } {
|
||||
let fileCount = 0;
|
||||
let folderCount = 0;
|
||||
|
||||
const count = (nodeList: FileTreeNode[]) => {
|
||||
for (const node of nodeList) {
|
||||
if (node.type === 'folder') {
|
||||
folderCount++;
|
||||
if (node.children) {
|
||||
count(node.children);
|
||||
}
|
||||
} else {
|
||||
fileCount++;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
count(nodes);
|
||||
return { fileCount, folderCount };
|
||||
}
|
||||
|
||||
/**
|
||||
* Find a node in the tree by path.
|
||||
* @param tree - The file tree to search
|
||||
* @param relativePath - Path relative to tree root
|
||||
* @returns The node if found, undefined otherwise
|
||||
*/
|
||||
export function findNodeInTree(
|
||||
tree: FileTreeNode[],
|
||||
relativePath: string
|
||||
): FileTreeNode | undefined {
|
||||
const parts = relativePath.split('/').filter(Boolean);
|
||||
if (parts.length === 0) return undefined;
|
||||
|
||||
let current: FileTreeNode[] = tree;
|
||||
for (let i = 0; i < parts.length; i++) {
|
||||
const node = current.find(n => n.name === parts[i]);
|
||||
if (!node) return undefined;
|
||||
if (i === parts.length - 1) return node;
|
||||
if (!node.children) return undefined;
|
||||
current = node.children;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||