feat: add download toast, e2e permission tests, and update favicons
- Add download toast component with progress indicator and auto-dismiss - Add download store for managing file download state - Add e2e tests for permission prompts (allow/deny flows) - Add global setup for e2e tests with isolated daemon instance - Update favicon assets with new design - Refactor agent-stream-view to use shared tool-call-details component - Simplify file-explorer-pane component - Clean up unused screenshots and PRODUCTION.md 🤖 Generated with [Claude Code](https://claude.com/claude-code)
|
Before Width: | Height: | Size: 118 KiB |
|
Before Width: | Height: | Size: 296 KiB |
|
Before Width: | Height: | Size: 233 KiB |
|
Before Width: | Height: | Size: 255 KiB |
|
Before Width: | Height: | Size: 283 KiB |
|
Before Width: | Height: | Size: 237 KiB |
|
Before Width: | Height: | Size: 237 KiB |
313
PRODUCTION.md
@@ -1,313 +0,0 @@
|
|||||||
# Production Architecture
|
|
||||||
|
|
||||||
## Overview
|
|
||||||
|
|
||||||
Paseo ships as a headless daemon distributed via npm, with native apps (iOS/Android) and a bundled web UI. All communication is end-to-end encrypted. By default, the daemon connects to Paseo Link for remote access.
|
|
||||||
|
|
||||||
## Distribution
|
|
||||||
|
|
||||||
```bash
|
|
||||||
npm install -g @paseo/daemon
|
|
||||||
paseo start
|
|
||||||
```
|
|
||||||
|
|
||||||
The daemon package includes the web UI bundle, served at `http://localhost:6767`.
|
|
||||||
|
|
||||||
### Package Structure
|
|
||||||
|
|
||||||
```
|
|
||||||
@paseo/daemon
|
|
||||||
├── dist/
|
|
||||||
│ ├── cli.js # Entry point
|
|
||||||
│ ├── server/ # Daemon code
|
|
||||||
│ └── public/ # Bundled web UI from @paseo/app
|
|
||||||
```
|
|
||||||
|
|
||||||
### CLI Commands
|
|
||||||
|
|
||||||
```bash
|
|
||||||
paseo start # Run in foreground, connect to Link
|
|
||||||
paseo start --daemon # Run in background
|
|
||||||
paseo start --no-link # Direct connections only (for VPN/Tailscale users)
|
|
||||||
paseo stop # Stop background daemon
|
|
||||||
paseo pair # Generate pairing code
|
|
||||||
paseo devices # List paired devices
|
|
||||||
paseo revoke <id> # Remove a paired device
|
|
||||||
```
|
|
||||||
|
|
||||||
## Security Model
|
|
||||||
|
|
||||||
### Threat Model
|
|
||||||
|
|
||||||
Pairing protects against:
|
|
||||||
- Malicious JS on websites trying to connect to localhost
|
|
||||||
- Network attackers (misconfigured firewall, shared network)
|
|
||||||
- Link server snooping (E2EE - it only sees encrypted bytes)
|
|
||||||
- Unauthorized local network users
|
|
||||||
|
|
||||||
Not protected (out of scope):
|
|
||||||
- Malicious processes running as your user (already have full access)
|
|
||||||
|
|
||||||
### Device Pairing
|
|
||||||
|
|
||||||
Every device must pair with the daemon before communicating. Pairing is a one-time event per device.
|
|
||||||
|
|
||||||
**Flow:**
|
|
||||||
|
|
||||||
1. User runs `paseo pair`
|
|
||||||
2. Daemon generates one-time token (e.g., `K3X9-M2B7`), held in memory
|
|
||||||
3. Token displayed as QR code + plaintext
|
|
||||||
4. Client connects and presents token
|
|
||||||
5. ECDH key exchange → shared secret derived
|
|
||||||
6. Token invalidated, device stored as paired
|
|
||||||
7. All future communication encrypted with derived key
|
|
||||||
|
|
||||||
**Token format:**
|
|
||||||
- 8 alphanumeric characters (no ambiguous chars: 0/O, 1/l/I)
|
|
||||||
- Expires after 5 minutes or single use
|
|
||||||
- Example: `K3X9-M2B7`
|
|
||||||
|
|
||||||
**Stored per device:**
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
interface PairedDevice {
|
|
||||||
id: string
|
|
||||||
name: string // "Mohamed's iPhone"
|
|
||||||
publicKey: string
|
|
||||||
pairedAt: Date
|
|
||||||
lastSeen: Date
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### End-to-End Encryption
|
|
||||||
|
|
||||||
All communication is E2EE, regardless of transport:
|
|
||||||
|
|
||||||
```
|
|
||||||
┌─────────────────────────────────────────────────┐
|
|
||||||
│ Transport │
|
|
||||||
│ (direct WS / Link / Tailscale / LAN) │
|
|
||||||
└─────────────────────────────────────────────────┘
|
|
||||||
│
|
|
||||||
▼
|
|
||||||
┌─────────────────────────────────────────────────┐
|
|
||||||
│ E2EE Layer (always on) │
|
|
||||||
│ Pairing token → key exchange │
|
|
||||||
└─────────────────────────────────────────────────┘
|
|
||||||
│
|
|
||||||
▼
|
|
||||||
┌─────────────────────────────────────────────────┐
|
|
||||||
│ Application Protocol │
|
|
||||||
└─────────────────────────────────────────────────┘
|
|
||||||
```
|
|
||||||
|
|
||||||
## Connectivity
|
|
||||||
|
|
||||||
### Paseo Link (default)
|
|
||||||
|
|
||||||
Remote access via `link.paseo.sh`:
|
|
||||||
|
|
||||||
```
|
|
||||||
Phone ──WS──▶ Cloudflare Edge ──▶ Durable Object (daemon-xyz)
|
|
||||||
▲
|
|
||||||
Daemon ──WS──▶ Cloudflare Edge ──────────┘
|
|
||||||
```
|
|
||||||
|
|
||||||
**Link properties:**
|
|
||||||
- Dumb pipe - forwards encrypted bytes only
|
|
||||||
- Cannot read traffic (E2EE)
|
|
||||||
- Stateful via Cloudflare Durable Objects
|
|
||||||
- Both daemon and clients routed to same DO instance by daemon ID
|
|
||||||
- Enabled by default, disable with `--no-link`
|
|
||||||
|
|
||||||
### Direct Access
|
|
||||||
|
|
||||||
For local network or VPN (Tailscale, etc.):
|
|
||||||
|
|
||||||
- Client connects directly to daemon WebSocket
|
|
||||||
- E2EE still required (paired device presents public key)
|
|
||||||
- No Link involved
|
|
||||||
- Use `paseo start --no-link` if you only want direct connections
|
|
||||||
|
|
||||||
**Pairing URL format:**
|
|
||||||
|
|
||||||
```
|
|
||||||
paseo://<daemon-id>@<link-or-direct-addr>?token=<one-time-pairing-token>
|
|
||||||
```
|
|
||||||
|
|
||||||
## Build & Bundling
|
|
||||||
|
|
||||||
### Web UI Bundling
|
|
||||||
|
|
||||||
The web UI is built from `@paseo/app` and copied into the daemon's dist:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"scripts": {
|
|
||||||
"build:app": "npm run build --workspace=@paseo/app -- --platform web",
|
|
||||||
"build:server": "tsc && cp -r ../app/dist/web ./dist/public",
|
|
||||||
"build": "npm run build:app && npm run build:server"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Server Static Serving
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
import express from 'express'
|
|
||||||
import path from 'path'
|
|
||||||
|
|
||||||
const app = express()
|
|
||||||
|
|
||||||
// API routes
|
|
||||||
app.use('/api', apiRouter)
|
|
||||||
|
|
||||||
// Static files - bundled web app
|
|
||||||
app.use(express.static(path.join(__dirname, 'public')))
|
|
||||||
|
|
||||||
// SPA fallback
|
|
||||||
app.get('*', (req, res) => {
|
|
||||||
res.sendFile(path.join(__dirname, 'public', 'index.html'))
|
|
||||||
})
|
|
||||||
```
|
|
||||||
|
|
||||||
## Daemon Process Management
|
|
||||||
|
|
||||||
### Background Mode
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
import { spawn } from 'child_process'
|
|
||||||
import fs from 'fs'
|
|
||||||
|
|
||||||
if (command === 'start' && process.argv.includes('--daemon')) {
|
|
||||||
const child = spawn(process.execPath, [__filename, 'start'], {
|
|
||||||
detached: true,
|
|
||||||
stdio: 'ignore',
|
|
||||||
})
|
|
||||||
child.unref()
|
|
||||||
fs.writeFileSync('~/.paseo/daemon.pid', child.pid.toString())
|
|
||||||
console.log(`Daemon started (pid: ${child.pid})`)
|
|
||||||
process.exit(0)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (command === 'stop') {
|
|
||||||
const pid = fs.readFileSync('~/.paseo/daemon.pid', 'utf-8')
|
|
||||||
process.kill(parseInt(pid))
|
|
||||||
fs.unlinkSync('~/.paseo/daemon.pid')
|
|
||||||
console.log('Daemon stopped')
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### System Service (optional)
|
|
||||||
|
|
||||||
For auto-start on boot, users can install a system service:
|
|
||||||
|
|
||||||
**macOS (launchd):**
|
|
||||||
|
|
||||||
```xml
|
|
||||||
<?xml version="1.0" encoding="UTF-8"?>
|
|
||||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
||||||
<plist version="1.0">
|
|
||||||
<dict>
|
|
||||||
<key>Label</key>
|
|
||||||
<string>com.paseo.daemon</string>
|
|
||||||
<key>ProgramArguments</key>
|
|
||||||
<array>
|
|
||||||
<string>/usr/local/bin/paseo</string>
|
|
||||||
<string>start</string>
|
|
||||||
</array>
|
|
||||||
<key>RunAtLoad</key>
|
|
||||||
<true/>
|
|
||||||
<key>KeepAlive</key>
|
|
||||||
<true/>
|
|
||||||
</dict>
|
|
||||||
</plist>
|
|
||||||
```
|
|
||||||
|
|
||||||
**Linux (systemd):**
|
|
||||||
|
|
||||||
```ini
|
|
||||||
[Unit]
|
|
||||||
Description=Paseo Daemon
|
|
||||||
After=network.target
|
|
||||||
|
|
||||||
[Service]
|
|
||||||
ExecStart=/usr/local/bin/paseo start
|
|
||||||
Restart=always
|
|
||||||
User=%u
|
|
||||||
|
|
||||||
[Install]
|
|
||||||
WantedBy=default.target
|
|
||||||
```
|
|
||||||
|
|
||||||
## Paseo Link Server (Cloudflare)
|
|
||||||
|
|
||||||
### Durable Object Implementation
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
export class LinkRoom extends DurableObject {
|
|
||||||
daemon: WebSocket | null = null
|
|
||||||
clients: Map<string, WebSocket> = new Map()
|
|
||||||
|
|
||||||
async fetch(req: Request) {
|
|
||||||
const upgradeHeader = req.headers.get('Upgrade')
|
|
||||||
if (upgradeHeader !== 'websocket') {
|
|
||||||
return new Response('Expected WebSocket', { status: 426 })
|
|
||||||
}
|
|
||||||
|
|
||||||
const [client, server] = Object.values(new WebSocketPair())
|
|
||||||
const role = req.headers.get('x-paseo-role')
|
|
||||||
const clientId = req.headers.get('x-paseo-client-id')
|
|
||||||
|
|
||||||
server.accept()
|
|
||||||
|
|
||||||
if (role === 'daemon') {
|
|
||||||
this.daemon = server
|
|
||||||
server.addEventListener('message', (e) => {
|
|
||||||
// Forward to all clients (they decrypt what's theirs)
|
|
||||||
this.clients.forEach(c => c.send(e.data))
|
|
||||||
})
|
|
||||||
} else {
|
|
||||||
this.clients.set(clientId, server)
|
|
||||||
server.addEventListener('message', (e) => {
|
|
||||||
this.daemon?.send(e.data)
|
|
||||||
})
|
|
||||||
server.addEventListener('close', () => {
|
|
||||||
this.clients.delete(clientId)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
return new Response(null, { status: 101, webSocket: client })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Worker Entry Point
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
export default {
|
|
||||||
async fetch(req: Request, env: Env) {
|
|
||||||
const url = new URL(req.url)
|
|
||||||
const daemonId = url.pathname.split('/')[2] // /link/<daemon-id>
|
|
||||||
|
|
||||||
const id = env.LINK_ROOMS.idFromName(daemonId)
|
|
||||||
const room = env.LINK_ROOMS.get(id)
|
|
||||||
|
|
||||||
return room.fetch(req)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Pricing Estimate
|
|
||||||
|
|
||||||
- Durable Objects: $0.15/million requests
|
|
||||||
- WebSocket messages count as requests
|
|
||||||
- Storage: $0.15/GB-month (minimal for Link)
|
|
||||||
- Expected cost for personal use: < $1/month
|
|
||||||
|
|
||||||
## Future Enhancements
|
|
||||||
|
|
||||||
- **Homebrew formula** for easier Mac installation
|
|
||||||
- **Docker image** for server deployments
|
|
||||||
- **Bundled Node binary** (pkg/nexe) for non-Node users
|
|
||||||
- **Auto-update mechanism** via npm or custom updater
|
|
||||||
|
Before Width: | Height: | Size: 269 KiB |
|
Before Width: | Height: | Size: 2.1 KiB After Width: | Height: | Size: 2.1 KiB |
@@ -1,4 +1,5 @@
|
|||||||
<svg width="48" height="48" viewBox="0 0 700 700" fill="none" xmlns="http://www.w3.org/2000/svg">
|
<svg width="48" height="48" viewBox="0 0 700 700" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<rect width="700" height="700" rx="100" fill="black"/>
|
||||||
<path d="M291.495 91.399C333.897 104.892 379.155 135.075 416.229 173.191C453.389 211.394 484.429 259.725 495.708 311.251C497.555 319.693 498.865 328.216 499.586 336.776C509.755 326.554 519.867 317.815 529.89 311.547C540.647 304.821 553.808 299.297 568.641 299.785C584.29 300.299 597.395 307.326 607.747 317.632C632.173 341.947 629.612 372.898 619.872 397.936C610.185 422.833 591.557 447.826 572.732 469.124C553.591 490.78 532.713 510.308 516.779 524.318C508.775 531.355 501.936 537.073 497.07 541.052C494.635 543.043 492.689 544.603 491.334 545.679C490.657 546.217 490.126 546.635 489.756 546.926C489.571 547.071 489.425 547.184 489.321 547.265C489.269 547.305 489.227 547.338 489.196 547.362C489.181 547.374 489.168 547.385 489.157 547.393C489.153 547.397 489.147 547.401 489.144 547.403C489.134 547.4 488.837 547.06 473.001 528.499L489.135 547.411C478.157 555.911 462.033 554.334 453.122 543.89C444.213 533.448 445.887 518.094 456.861 509.592C456.863 509.591 456.865 509.588 456.869 509.586C456.88 509.577 456.902 509.561 456.933 509.536C456.997 509.487 457.101 509.404 457.245 509.292C457.533 509.066 457.979 508.715 458.569 508.247C459.749 507.31 461.506 505.901 463.742 504.073C468.216 500.414 474.589 495.088 482.073 488.508C497.114 475.284 516.315 457.282 533.578 437.75C551.157 417.862 565.26 398.01 571.859 381.048C578.403 364.227 575.681 356.302 570.724 351.367C568.928 349.579 567.744 348.902 567.267 348.676C566.888 348.496 566.811 348.52 566.804 348.52C566.605 348.513 563.971 348.537 557.953 352.3C545.161 360.299 528.815 377.492 506.807 403.867C494.927 418.106 481.871 434.435 467.547 451.957C463.709 457.28 459.503 462.538 454.91 467.717L454.702 467.549C420.808 508.347 380.37 553.856 332.335 593.848C301.853 619.226 262.656 622.597 228.642 614.743C194.834 606.936 162.658 587.448 142.217 561.686C108.054 518.631 100.57 469.801 108.223 427.836C115.56 387.606 137.391 351.005 166.502 331.557C161.248 315.813 156.813 299.49 153.519 283.013C142.593 228.368 143.239 167.031 174.28 119.619C186.922 100.31 205.846 89.1535 227.387 85.2773C248.1 81.5504 270.278 84.648 291.495 91.399ZM378.642 206.356C345.773 172.563 307.463 147.917 275.208 137.654C259.096 132.527 246.171 131.514 236.828 133.195C228.314 134.727 222.227 138.497 217.721 145.38C196.712 177.468 193.858 224.004 203.82 273.827C206.532 287.394 210.127 300.834 214.345 313.817C236.45 310.276 260.156 311.463 281.22 317.11C319.621 327.403 357.501 355.419 357.501 405.654C357.501 435.255 339.111 465.136 307.278 473.815C273.211 483.103 238.854 464.822 213.105 427.541C203.716 413.947 194.443 397.766 185.947 379.89C174.028 392.223 163.08 411.953 158.673 436.118C153.128 466.518 158.514 501.286 183.085 532.253C195.993 548.522 217.742 562.031 240.771 567.349C263.594 572.619 284.147 569.24 298.664 557.154C349.383 514.927 390.709 466.547 426.366 422.952C448.879 390.86 453.195 356.06 445.578 321.265C436.703 280.718 411.425 240.06 378.642 206.356ZM306.296 405.722C306.296 384.769 292.223 370.736 267.284 364.051C256.012 361.03 244.156 360.087 233.095 360.771C240.361 375.935 248.168 389.513 255.897 400.704C275.647 429.298 289.989 427.822 293.247 426.934C298.737 425.437 306.296 418.161 306.296 405.722Z" fill="white"/>
|
<path d="M291.495 91.399C333.897 104.892 379.155 135.075 416.229 173.191C453.389 211.394 484.429 259.725 495.708 311.251C497.555 319.693 498.865 328.216 499.586 336.776C509.755 326.554 519.867 317.815 529.89 311.547C540.647 304.821 553.808 299.297 568.641 299.785C584.29 300.299 597.395 307.326 607.747 317.632C632.173 341.947 629.612 372.898 619.872 397.936C610.185 422.833 591.557 447.826 572.732 469.124C553.591 490.78 532.713 510.308 516.779 524.318C508.775 531.355 501.936 537.073 497.07 541.052C494.635 543.043 492.689 544.603 491.334 545.679C490.657 546.217 490.126 546.635 489.756 546.926C489.571 547.071 489.425 547.184 489.321 547.265C489.269 547.305 489.227 547.338 489.196 547.362C489.181 547.374 489.168 547.385 489.157 547.393C489.153 547.397 489.147 547.401 489.144 547.403C489.134 547.4 488.837 547.06 473.001 528.499L489.135 547.411C478.157 555.911 462.033 554.334 453.122 543.89C444.213 533.448 445.887 518.094 456.861 509.592C456.863 509.591 456.865 509.588 456.869 509.586C456.88 509.577 456.902 509.561 456.933 509.536C456.997 509.487 457.101 509.404 457.245 509.292C457.533 509.066 457.979 508.715 458.569 508.247C459.749 507.31 461.506 505.901 463.742 504.073C468.216 500.414 474.589 495.088 482.073 488.508C497.114 475.284 516.315 457.282 533.578 437.75C551.157 417.862 565.26 398.01 571.859 381.048C578.403 364.227 575.681 356.302 570.724 351.367C568.928 349.579 567.744 348.902 567.267 348.676C566.888 348.496 566.811 348.52 566.804 348.52C566.605 348.513 563.971 348.537 557.953 352.3C545.161 360.299 528.815 377.492 506.807 403.867C494.927 418.106 481.871 434.435 467.547 451.957C463.709 457.28 459.503 462.538 454.91 467.717L454.702 467.549C420.808 508.347 380.37 553.856 332.335 593.848C301.853 619.226 262.656 622.597 228.642 614.743C194.834 606.936 162.658 587.448 142.217 561.686C108.054 518.631 100.57 469.801 108.223 427.836C115.56 387.606 137.391 351.005 166.502 331.557C161.248 315.813 156.813 299.49 153.519 283.013C142.593 228.368 143.239 167.031 174.28 119.619C186.922 100.31 205.846 89.1535 227.387 85.2773C248.1 81.5504 270.278 84.648 291.495 91.399ZM378.642 206.356C345.773 172.563 307.463 147.917 275.208 137.654C259.096 132.527 246.171 131.514 236.828 133.195C228.314 134.727 222.227 138.497 217.721 145.38C196.712 177.468 193.858 224.004 203.82 273.827C206.532 287.394 210.127 300.834 214.345 313.817C236.45 310.276 260.156 311.463 281.22 317.11C319.621 327.403 357.501 355.419 357.501 405.654C357.501 435.255 339.111 465.136 307.278 473.815C273.211 483.103 238.854 464.822 213.105 427.541C203.716 413.947 194.443 397.766 185.947 379.89C174.028 392.223 163.08 411.953 158.673 436.118C153.128 466.518 158.514 501.286 183.085 532.253C195.993 548.522 217.742 562.031 240.771 567.349C263.594 572.619 284.147 569.24 298.664 557.154C349.383 514.927 390.709 466.547 426.366 422.952C448.879 390.86 453.195 356.06 445.578 321.265C436.703 280.718 411.425 240.06 378.642 206.356ZM306.296 405.722C306.296 384.769 292.223 370.736 267.284 364.051C256.012 361.03 244.156 360.087 233.095 360.771C240.361 375.935 248.168 389.513 255.897 400.704C275.647 429.298 289.989 427.822 293.247 426.934C298.737 425.437 306.296 418.161 306.296 405.722Z" fill="white"/>
|
||||||
<circle cx="570" cy="570" r="130" fill="#22c55e"/>
|
<circle cx="570" cy="570" r="130" fill="#22c55e"/>
|
||||||
</svg>
|
</svg>
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 3.3 KiB After Width: | Height: | Size: 3.3 KiB |
|
Before Width: | Height: | Size: 2.1 KiB After Width: | Height: | Size: 2.1 KiB |
@@ -1,4 +1,5 @@
|
|||||||
<svg width="48" height="48" viewBox="0 0 700 700" fill="none" xmlns="http://www.w3.org/2000/svg">
|
<svg width="48" height="48" viewBox="0 0 700 700" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<rect width="700" height="700" rx="100" fill="black"/>
|
||||||
<path d="M291.495 91.399C333.897 104.892 379.155 135.075 416.229 173.191C453.389 211.394 484.429 259.725 495.708 311.251C497.555 319.693 498.865 328.216 499.586 336.776C509.755 326.554 519.867 317.815 529.89 311.547C540.647 304.821 553.808 299.297 568.641 299.785C584.29 300.299 597.395 307.326 607.747 317.632C632.173 341.947 629.612 372.898 619.872 397.936C610.185 422.833 591.557 447.826 572.732 469.124C553.591 490.78 532.713 510.308 516.779 524.318C508.775 531.355 501.936 537.073 497.07 541.052C494.635 543.043 492.689 544.603 491.334 545.679C490.657 546.217 490.126 546.635 489.756 546.926C489.571 547.071 489.425 547.184 489.321 547.265C489.269 547.305 489.227 547.338 489.196 547.362C489.181 547.374 489.168 547.385 489.157 547.393C489.153 547.397 489.147 547.401 489.144 547.403C489.134 547.4 488.837 547.06 473.001 528.499L489.135 547.411C478.157 555.911 462.033 554.334 453.122 543.89C444.213 533.448 445.887 518.094 456.861 509.592C456.863 509.591 456.865 509.588 456.869 509.586C456.88 509.577 456.902 509.561 456.933 509.536C456.997 509.487 457.101 509.404 457.245 509.292C457.533 509.066 457.979 508.715 458.569 508.247C459.749 507.31 461.506 505.901 463.742 504.073C468.216 500.414 474.589 495.088 482.073 488.508C497.114 475.284 516.315 457.282 533.578 437.75C551.157 417.862 565.26 398.01 571.859 381.048C578.403 364.227 575.681 356.302 570.724 351.367C568.928 349.579 567.744 348.902 567.267 348.676C566.888 348.496 566.811 348.52 566.804 348.52C566.605 348.513 563.971 348.537 557.953 352.3C545.161 360.299 528.815 377.492 506.807 403.867C494.927 418.106 481.871 434.435 467.547 451.957C463.709 457.28 459.503 462.538 454.91 467.717L454.702 467.549C420.808 508.347 380.37 553.856 332.335 593.848C301.853 619.226 262.656 622.597 228.642 614.743C194.834 606.936 162.658 587.448 142.217 561.686C108.054 518.631 100.57 469.801 108.223 427.836C115.56 387.606 137.391 351.005 166.502 331.557C161.248 315.813 156.813 299.49 153.519 283.013C142.593 228.368 143.239 167.031 174.28 119.619C186.922 100.31 205.846 89.1535 227.387 85.2773C248.1 81.5504 270.278 84.648 291.495 91.399ZM378.642 206.356C345.773 172.563 307.463 147.917 275.208 137.654C259.096 132.527 246.171 131.514 236.828 133.195C228.314 134.727 222.227 138.497 217.721 145.38C196.712 177.468 193.858 224.004 203.82 273.827C206.532 287.394 210.127 300.834 214.345 313.817C236.45 310.276 260.156 311.463 281.22 317.11C319.621 327.403 357.501 355.419 357.501 405.654C357.501 435.255 339.111 465.136 307.278 473.815C273.211 483.103 238.854 464.822 213.105 427.541C203.716 413.947 194.443 397.766 185.947 379.89C174.028 392.223 163.08 411.953 158.673 436.118C153.128 466.518 158.514 501.286 183.085 532.253C195.993 548.522 217.742 562.031 240.771 567.349C263.594 572.619 284.147 569.24 298.664 557.154C349.383 514.927 390.709 466.547 426.366 422.952C448.879 390.86 453.195 356.06 445.578 321.265C436.703 280.718 411.425 240.06 378.642 206.356ZM306.296 405.722C306.296 384.769 292.223 370.736 267.284 364.051C256.012 361.03 244.156 360.087 233.095 360.771C240.361 375.935 248.168 389.513 255.897 400.704C275.647 429.298 289.989 427.822 293.247 426.934C298.737 425.437 306.296 418.161 306.296 405.722Z" fill="white"/>
|
<path d="M291.495 91.399C333.897 104.892 379.155 135.075 416.229 173.191C453.389 211.394 484.429 259.725 495.708 311.251C497.555 319.693 498.865 328.216 499.586 336.776C509.755 326.554 519.867 317.815 529.89 311.547C540.647 304.821 553.808 299.297 568.641 299.785C584.29 300.299 597.395 307.326 607.747 317.632C632.173 341.947 629.612 372.898 619.872 397.936C610.185 422.833 591.557 447.826 572.732 469.124C553.591 490.78 532.713 510.308 516.779 524.318C508.775 531.355 501.936 537.073 497.07 541.052C494.635 543.043 492.689 544.603 491.334 545.679C490.657 546.217 490.126 546.635 489.756 546.926C489.571 547.071 489.425 547.184 489.321 547.265C489.269 547.305 489.227 547.338 489.196 547.362C489.181 547.374 489.168 547.385 489.157 547.393C489.153 547.397 489.147 547.401 489.144 547.403C489.134 547.4 488.837 547.06 473.001 528.499L489.135 547.411C478.157 555.911 462.033 554.334 453.122 543.89C444.213 533.448 445.887 518.094 456.861 509.592C456.863 509.591 456.865 509.588 456.869 509.586C456.88 509.577 456.902 509.561 456.933 509.536C456.997 509.487 457.101 509.404 457.245 509.292C457.533 509.066 457.979 508.715 458.569 508.247C459.749 507.31 461.506 505.901 463.742 504.073C468.216 500.414 474.589 495.088 482.073 488.508C497.114 475.284 516.315 457.282 533.578 437.75C551.157 417.862 565.26 398.01 571.859 381.048C578.403 364.227 575.681 356.302 570.724 351.367C568.928 349.579 567.744 348.902 567.267 348.676C566.888 348.496 566.811 348.52 566.804 348.52C566.605 348.513 563.971 348.537 557.953 352.3C545.161 360.299 528.815 377.492 506.807 403.867C494.927 418.106 481.871 434.435 467.547 451.957C463.709 457.28 459.503 462.538 454.91 467.717L454.702 467.549C420.808 508.347 380.37 553.856 332.335 593.848C301.853 619.226 262.656 622.597 228.642 614.743C194.834 606.936 162.658 587.448 142.217 561.686C108.054 518.631 100.57 469.801 108.223 427.836C115.56 387.606 137.391 351.005 166.502 331.557C161.248 315.813 156.813 299.49 153.519 283.013C142.593 228.368 143.239 167.031 174.28 119.619C186.922 100.31 205.846 89.1535 227.387 85.2773C248.1 81.5504 270.278 84.648 291.495 91.399ZM378.642 206.356C345.773 172.563 307.463 147.917 275.208 137.654C259.096 132.527 246.171 131.514 236.828 133.195C228.314 134.727 222.227 138.497 217.721 145.38C196.712 177.468 193.858 224.004 203.82 273.827C206.532 287.394 210.127 300.834 214.345 313.817C236.45 310.276 260.156 311.463 281.22 317.11C319.621 327.403 357.501 355.419 357.501 405.654C357.501 435.255 339.111 465.136 307.278 473.815C273.211 483.103 238.854 464.822 213.105 427.541C203.716 413.947 194.443 397.766 185.947 379.89C174.028 392.223 163.08 411.953 158.673 436.118C153.128 466.518 158.514 501.286 183.085 532.253C195.993 548.522 217.742 562.031 240.771 567.349C263.594 572.619 284.147 569.24 298.664 557.154C349.383 514.927 390.709 466.547 426.366 422.952C448.879 390.86 453.195 356.06 445.578 321.265C436.703 280.718 411.425 240.06 378.642 206.356ZM306.296 405.722C306.296 384.769 292.223 370.736 267.284 364.051C256.012 361.03 244.156 360.087 233.095 360.771C240.361 375.935 248.168 389.513 255.897 400.704C275.647 429.298 289.989 427.822 293.247 426.934C298.737 425.437 306.296 418.161 306.296 405.722Z" fill="white"/>
|
||||||
<circle cx="570" cy="570" r="130" fill="#3b82f6"/>
|
<circle cx="570" cy="570" r="130" fill="#3b82f6"/>
|
||||||
</svg>
|
</svg>
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 3.3 KiB After Width: | Height: | Size: 3.3 KiB |
|
Before Width: | Height: | Size: 1.1 KiB After Width: | Height: | Size: 1.7 KiB |
@@ -1,3 +1,4 @@
|
|||||||
<svg width="48" height="48" viewBox="0 0 700 700" fill="none" xmlns="http://www.w3.org/2000/svg">
|
<svg width="48" height="48" viewBox="0 0 700 700" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<rect width="700" height="700" rx="100" fill="black"/>
|
||||||
<path d="M291.495 91.399C333.897 104.892 379.155 135.075 416.229 173.191C453.389 211.394 484.429 259.725 495.708 311.251C497.555 319.693 498.865 328.216 499.586 336.776C509.755 326.554 519.867 317.815 529.89 311.547C540.647 304.821 553.808 299.297 568.641 299.785C584.29 300.299 597.395 307.326 607.747 317.632C632.173 341.947 629.612 372.898 619.872 397.936C610.185 422.833 591.557 447.826 572.732 469.124C553.591 490.78 532.713 510.308 516.779 524.318C508.775 531.355 501.936 537.073 497.07 541.052C494.635 543.043 492.689 544.603 491.334 545.679C490.657 546.217 490.126 546.635 489.756 546.926C489.571 547.071 489.425 547.184 489.321 547.265C489.269 547.305 489.227 547.338 489.196 547.362C489.181 547.374 489.168 547.385 489.157 547.393C489.153 547.397 489.147 547.401 489.144 547.403C489.134 547.4 488.837 547.06 473.001 528.499L489.135 547.411C478.157 555.911 462.033 554.334 453.122 543.89C444.213 533.448 445.887 518.094 456.861 509.592C456.863 509.591 456.865 509.588 456.869 509.586C456.88 509.577 456.902 509.561 456.933 509.536C456.997 509.487 457.101 509.404 457.245 509.292C457.533 509.066 457.979 508.715 458.569 508.247C459.749 507.31 461.506 505.901 463.742 504.073C468.216 500.414 474.589 495.088 482.073 488.508C497.114 475.284 516.315 457.282 533.578 437.75C551.157 417.862 565.26 398.01 571.859 381.048C578.403 364.227 575.681 356.302 570.724 351.367C568.928 349.579 567.744 348.902 567.267 348.676C566.888 348.496 566.811 348.52 566.804 348.52C566.605 348.513 563.971 348.537 557.953 352.3C545.161 360.299 528.815 377.492 506.807 403.867C494.927 418.106 481.871 434.435 467.547 451.957C463.709 457.28 459.503 462.538 454.91 467.717L454.702 467.549C420.808 508.347 380.37 553.856 332.335 593.848C301.853 619.226 262.656 622.597 228.642 614.743C194.834 606.936 162.658 587.448 142.217 561.686C108.054 518.631 100.57 469.801 108.223 427.836C115.56 387.606 137.391 351.005 166.502 331.557C161.248 315.813 156.813 299.49 153.519 283.013C142.593 228.368 143.239 167.031 174.28 119.619C186.922 100.31 205.846 89.1535 227.387 85.2773C248.1 81.5504 270.278 84.648 291.495 91.399ZM378.642 206.356C345.773 172.563 307.463 147.917 275.208 137.654C259.096 132.527 246.171 131.514 236.828 133.195C228.314 134.727 222.227 138.497 217.721 145.38C196.712 177.468 193.858 224.004 203.82 273.827C206.532 287.394 210.127 300.834 214.345 313.817C236.45 310.276 260.156 311.463 281.22 317.11C319.621 327.403 357.501 355.419 357.501 405.654C357.501 435.255 339.111 465.136 307.278 473.815C273.211 483.103 238.854 464.822 213.105 427.541C203.716 413.947 194.443 397.766 185.947 379.89C174.028 392.223 163.08 411.953 158.673 436.118C153.128 466.518 158.514 501.286 183.085 532.253C195.993 548.522 217.742 562.031 240.771 567.349C263.594 572.619 284.147 569.24 298.664 557.154C349.383 514.927 390.709 466.547 426.366 422.952C448.879 390.86 453.195 356.06 445.578 321.265C436.703 280.718 411.425 240.06 378.642 206.356ZM306.296 405.722C306.296 384.769 292.223 370.736 267.284 364.051C256.012 361.03 244.156 360.087 233.095 360.771C240.361 375.935 248.168 389.513 255.897 400.704C275.647 429.298 289.989 427.822 293.247 426.934C298.737 425.437 306.296 418.161 306.296 405.722Z" fill="white"/>
|
<path d="M291.495 91.399C333.897 104.892 379.155 135.075 416.229 173.191C453.389 211.394 484.429 259.725 495.708 311.251C497.555 319.693 498.865 328.216 499.586 336.776C509.755 326.554 519.867 317.815 529.89 311.547C540.647 304.821 553.808 299.297 568.641 299.785C584.29 300.299 597.395 307.326 607.747 317.632C632.173 341.947 629.612 372.898 619.872 397.936C610.185 422.833 591.557 447.826 572.732 469.124C553.591 490.78 532.713 510.308 516.779 524.318C508.775 531.355 501.936 537.073 497.07 541.052C494.635 543.043 492.689 544.603 491.334 545.679C490.657 546.217 490.126 546.635 489.756 546.926C489.571 547.071 489.425 547.184 489.321 547.265C489.269 547.305 489.227 547.338 489.196 547.362C489.181 547.374 489.168 547.385 489.157 547.393C489.153 547.397 489.147 547.401 489.144 547.403C489.134 547.4 488.837 547.06 473.001 528.499L489.135 547.411C478.157 555.911 462.033 554.334 453.122 543.89C444.213 533.448 445.887 518.094 456.861 509.592C456.863 509.591 456.865 509.588 456.869 509.586C456.88 509.577 456.902 509.561 456.933 509.536C456.997 509.487 457.101 509.404 457.245 509.292C457.533 509.066 457.979 508.715 458.569 508.247C459.749 507.31 461.506 505.901 463.742 504.073C468.216 500.414 474.589 495.088 482.073 488.508C497.114 475.284 516.315 457.282 533.578 437.75C551.157 417.862 565.26 398.01 571.859 381.048C578.403 364.227 575.681 356.302 570.724 351.367C568.928 349.579 567.744 348.902 567.267 348.676C566.888 348.496 566.811 348.52 566.804 348.52C566.605 348.513 563.971 348.537 557.953 352.3C545.161 360.299 528.815 377.492 506.807 403.867C494.927 418.106 481.871 434.435 467.547 451.957C463.709 457.28 459.503 462.538 454.91 467.717L454.702 467.549C420.808 508.347 380.37 553.856 332.335 593.848C301.853 619.226 262.656 622.597 228.642 614.743C194.834 606.936 162.658 587.448 142.217 561.686C108.054 518.631 100.57 469.801 108.223 427.836C115.56 387.606 137.391 351.005 166.502 331.557C161.248 315.813 156.813 299.49 153.519 283.013C142.593 228.368 143.239 167.031 174.28 119.619C186.922 100.31 205.846 89.1535 227.387 85.2773C248.1 81.5504 270.278 84.648 291.495 91.399ZM378.642 206.356C345.773 172.563 307.463 147.917 275.208 137.654C259.096 132.527 246.171 131.514 236.828 133.195C228.314 134.727 222.227 138.497 217.721 145.38C196.712 177.468 193.858 224.004 203.82 273.827C206.532 287.394 210.127 300.834 214.345 313.817C236.45 310.276 260.156 311.463 281.22 317.11C319.621 327.403 357.501 355.419 357.501 405.654C357.501 435.255 339.111 465.136 307.278 473.815C273.211 483.103 238.854 464.822 213.105 427.541C203.716 413.947 194.443 397.766 185.947 379.89C174.028 392.223 163.08 411.953 158.673 436.118C153.128 466.518 158.514 501.286 183.085 532.253C195.993 548.522 217.742 562.031 240.771 567.349C263.594 572.619 284.147 569.24 298.664 557.154C349.383 514.927 390.709 466.547 426.366 422.952C448.879 390.86 453.195 356.06 445.578 321.265C436.703 280.718 411.425 240.06 378.642 206.356ZM306.296 405.722C306.296 384.769 292.223 370.736 267.284 364.051C256.012 361.03 244.156 360.087 233.095 360.771C240.361 375.935 248.168 389.513 255.897 400.704C275.647 429.298 289.989 427.822 293.247 426.934C298.737 425.437 306.296 418.161 306.296 405.722Z" fill="white"/>
|
||||||
</svg>
|
</svg>
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 3.2 KiB After Width: | Height: | Size: 3.3 KiB |
|
Before Width: | Height: | Size: 2.3 KiB After Width: | Height: | Size: 2.1 KiB |
@@ -1,4 +1,5 @@
|
|||||||
<svg width="48" height="48" viewBox="0 0 700 700" fill="none" xmlns="http://www.w3.org/2000/svg">
|
<svg width="48" height="48" viewBox="0 0 700 700" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
<path d="M291.495 91.399C333.897 104.892 379.155 135.075 416.229 173.191C453.389 211.394 484.429 259.725 495.708 311.251C497.555 319.693 498.865 328.216 499.586 336.776C509.755 326.554 519.867 317.815 529.89 311.547C540.647 304.821 553.808 299.297 568.641 299.785C584.29 300.299 597.395 307.326 607.747 317.632C632.173 341.947 629.612 372.898 619.872 397.936C610.185 422.833 591.557 447.826 572.732 469.124C553.591 490.78 532.713 510.308 516.779 524.318C508.775 531.355 501.936 537.073 497.07 541.052C494.635 543.043 492.689 544.603 491.334 545.679C490.657 546.217 490.126 546.635 489.756 546.926C489.571 547.071 489.425 547.184 489.321 547.265C489.269 547.305 489.227 547.338 489.196 547.362C489.181 547.374 489.168 547.385 489.157 547.393C489.153 547.397 489.147 547.401 489.144 547.403C489.134 547.4 488.837 547.06 473.001 528.499L489.135 547.411C478.157 555.911 462.033 554.334 453.122 543.89C444.213 533.448 445.887 518.094 456.861 509.592C456.863 509.591 456.865 509.588 456.869 509.586C456.88 509.577 456.902 509.561 456.933 509.536C456.997 509.487 457.101 509.404 457.245 509.292C457.533 509.066 457.979 508.715 458.569 508.247C459.749 507.31 461.506 505.901 463.742 504.073C468.216 500.414 474.589 495.088 482.073 488.508C497.114 475.284 516.315 457.282 533.578 437.75C551.157 417.862 565.26 398.01 571.859 381.048C578.403 364.227 575.681 356.302 570.724 351.367C568.928 349.579 567.744 348.902 567.267 348.676C566.888 348.496 566.811 348.52 566.804 348.52C566.605 348.513 563.971 348.537 557.953 352.3C545.161 360.299 528.815 377.492 506.807 403.867C494.927 418.106 481.871 434.435 467.547 451.957C463.709 457.28 459.503 462.538 454.91 467.717L454.702 467.549C420.808 508.347 380.37 553.856 332.335 593.848C301.853 619.226 262.656 622.597 228.642 614.743C194.834 606.936 162.658 587.448 142.217 561.686C108.054 518.631 100.57 469.801 108.223 427.836C115.56 387.606 137.391 351.005 166.502 331.557C161.248 315.813 156.813 299.49 153.519 283.013C142.593 228.368 143.239 167.031 174.28 119.619C186.922 100.31 205.846 89.1535 227.387 85.2773C248.1 81.5504 270.278 84.648 291.495 91.399ZM378.642 206.356C345.773 172.563 307.463 147.917 275.208 137.654C259.096 132.527 246.171 131.514 236.828 133.195C228.314 134.727 222.227 138.497 217.721 145.38C196.712 177.468 193.858 224.004 203.82 273.827C206.532 287.394 210.127 300.834 214.345 313.817C236.45 310.276 260.156 311.463 281.22 317.11C319.621 327.403 357.501 355.419 357.501 405.654C357.501 435.255 339.111 465.136 307.278 473.815C273.211 483.103 238.854 464.822 213.105 427.541C203.716 413.947 194.443 397.766 185.947 379.89C174.028 392.223 163.08 411.953 158.673 436.118C153.128 466.518 158.514 501.286 183.085 532.253C195.993 548.522 217.742 562.031 240.771 567.349C263.594 572.619 284.147 569.24 298.664 557.154C349.383 514.927 390.709 466.547 426.366 422.952C448.879 390.86 453.195 356.06 445.578 321.265C436.703 280.718 411.425 240.06 378.642 206.356ZM306.296 405.722C306.296 384.769 292.223 370.736 267.284 364.051C256.012 361.03 244.156 360.087 233.095 360.771C240.361 375.935 248.168 389.513 255.897 400.704C275.647 429.298 289.989 427.822 293.247 426.934C298.737 425.437 306.296 418.161 306.296 405.722Z" fill="#20744A"/>
|
<rect width="700" height="700" rx="100" fill="black"/>
|
||||||
|
<path d="M291.495 91.399C333.897 104.892 379.155 135.075 416.229 173.191C453.389 211.394 484.429 259.725 495.708 311.251C497.555 319.693 498.865 328.216 499.586 336.776C509.755 326.554 519.867 317.815 529.89 311.547C540.647 304.821 553.808 299.297 568.641 299.785C584.29 300.299 597.395 307.326 607.747 317.632C632.173 341.947 629.612 372.898 619.872 397.936C610.185 422.833 591.557 447.826 572.732 469.124C553.591 490.78 532.713 510.308 516.779 524.318C508.775 531.355 501.936 537.073 497.07 541.052C494.635 543.043 492.689 544.603 491.334 545.679C490.657 546.217 490.126 546.635 489.756 546.926C489.571 547.071 489.425 547.184 489.321 547.265C489.269 547.305 489.227 547.338 489.196 547.362C489.181 547.374 489.168 547.385 489.157 547.393C489.153 547.397 489.147 547.401 489.144 547.403C489.134 547.4 488.837 547.06 473.001 528.499L489.135 547.411C478.157 555.911 462.033 554.334 453.122 543.89C444.213 533.448 445.887 518.094 456.861 509.592C456.863 509.591 456.865 509.588 456.869 509.586C456.88 509.577 456.902 509.561 456.933 509.536C456.997 509.487 457.101 509.404 457.245 509.292C457.533 509.066 457.979 508.715 458.569 508.247C459.749 507.31 461.506 505.901 463.742 504.073C468.216 500.414 474.589 495.088 482.073 488.508C497.114 475.284 516.315 457.282 533.578 437.75C551.157 417.862 565.26 398.01 571.859 381.048C578.403 364.227 575.681 356.302 570.724 351.367C568.928 349.579 567.744 348.902 567.267 348.676C566.888 348.496 566.811 348.52 566.804 348.52C566.605 348.513 563.971 348.537 557.953 352.3C545.161 360.299 528.815 377.492 506.807 403.867C494.927 418.106 481.871 434.435 467.547 451.957C463.709 457.28 459.503 462.538 454.91 467.717L454.702 467.549C420.808 508.347 380.37 553.856 332.335 593.848C301.853 619.226 262.656 622.597 228.642 614.743C194.834 606.936 162.658 587.448 142.217 561.686C108.054 518.631 100.57 469.801 108.223 427.836C115.56 387.606 137.391 351.005 166.502 331.557C161.248 315.813 156.813 299.49 153.519 283.013C142.593 228.368 143.239 167.031 174.28 119.619C186.922 100.31 205.846 89.1535 227.387 85.2773C248.1 81.5504 270.278 84.648 291.495 91.399ZM378.642 206.356C345.773 172.563 307.463 147.917 275.208 137.654C259.096 132.527 246.171 131.514 236.828 133.195C228.314 134.727 222.227 138.497 217.721 145.38C196.712 177.468 193.858 224.004 203.82 273.827C206.532 287.394 210.127 300.834 214.345 313.817C236.45 310.276 260.156 311.463 281.22 317.11C319.621 327.403 357.501 355.419 357.501 405.654C357.501 435.255 339.111 465.136 307.278 473.815C273.211 483.103 238.854 464.822 213.105 427.541C203.716 413.947 194.443 397.766 185.947 379.89C174.028 392.223 163.08 411.953 158.673 436.118C153.128 466.518 158.514 501.286 183.085 532.253C195.993 548.522 217.742 562.031 240.771 567.349C263.594 572.619 284.147 569.24 298.664 557.154C349.383 514.927 390.709 466.547 426.366 422.952C448.879 390.86 453.195 356.06 445.578 321.265C436.703 280.718 411.425 240.06 378.642 206.356ZM306.296 405.722C306.296 384.769 292.223 370.736 267.284 364.051C256.012 361.03 244.156 360.087 233.095 360.771C240.361 375.935 248.168 389.513 255.897 400.704C275.647 429.298 289.989 427.822 293.247 426.934C298.737 425.437 306.296 418.161 306.296 405.722Z" fill="white"/>
|
||||||
<circle cx="570" cy="570" r="130" fill="#22c55e"/>
|
<circle cx="570" cy="570" r="130" fill="#22c55e"/>
|
||||||
</svg>
|
</svg>
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 3.3 KiB After Width: | Height: | Size: 3.3 KiB |
|
Before Width: | Height: | Size: 2.3 KiB After Width: | Height: | Size: 2.1 KiB |
@@ -1,4 +1,5 @@
|
|||||||
<svg width="48" height="48" viewBox="0 0 700 700" fill="none" xmlns="http://www.w3.org/2000/svg">
|
<svg width="48" height="48" viewBox="0 0 700 700" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
<path d="M291.495 91.399C333.897 104.892 379.155 135.075 416.229 173.191C453.389 211.394 484.429 259.725 495.708 311.251C497.555 319.693 498.865 328.216 499.586 336.776C509.755 326.554 519.867 317.815 529.89 311.547C540.647 304.821 553.808 299.297 568.641 299.785C584.29 300.299 597.395 307.326 607.747 317.632C632.173 341.947 629.612 372.898 619.872 397.936C610.185 422.833 591.557 447.826 572.732 469.124C553.591 490.78 532.713 510.308 516.779 524.318C508.775 531.355 501.936 537.073 497.07 541.052C494.635 543.043 492.689 544.603 491.334 545.679C490.657 546.217 490.126 546.635 489.756 546.926C489.571 547.071 489.425 547.184 489.321 547.265C489.269 547.305 489.227 547.338 489.196 547.362C489.181 547.374 489.168 547.385 489.157 547.393C489.153 547.397 489.147 547.401 489.144 547.403C489.134 547.4 488.837 547.06 473.001 528.499L489.135 547.411C478.157 555.911 462.033 554.334 453.122 543.89C444.213 533.448 445.887 518.094 456.861 509.592C456.863 509.591 456.865 509.588 456.869 509.586C456.88 509.577 456.902 509.561 456.933 509.536C456.997 509.487 457.101 509.404 457.245 509.292C457.533 509.066 457.979 508.715 458.569 508.247C459.749 507.31 461.506 505.901 463.742 504.073C468.216 500.414 474.589 495.088 482.073 488.508C497.114 475.284 516.315 457.282 533.578 437.75C551.157 417.862 565.26 398.01 571.859 381.048C578.403 364.227 575.681 356.302 570.724 351.367C568.928 349.579 567.744 348.902 567.267 348.676C566.888 348.496 566.811 348.52 566.804 348.52C566.605 348.513 563.971 348.537 557.953 352.3C545.161 360.299 528.815 377.492 506.807 403.867C494.927 418.106 481.871 434.435 467.547 451.957C463.709 457.28 459.503 462.538 454.91 467.717L454.702 467.549C420.808 508.347 380.37 553.856 332.335 593.848C301.853 619.226 262.656 622.597 228.642 614.743C194.834 606.936 162.658 587.448 142.217 561.686C108.054 518.631 100.57 469.801 108.223 427.836C115.56 387.606 137.391 351.005 166.502 331.557C161.248 315.813 156.813 299.49 153.519 283.013C142.593 228.368 143.239 167.031 174.28 119.619C186.922 100.31 205.846 89.1535 227.387 85.2773C248.1 81.5504 270.278 84.648 291.495 91.399ZM378.642 206.356C345.773 172.563 307.463 147.917 275.208 137.654C259.096 132.527 246.171 131.514 236.828 133.195C228.314 134.727 222.227 138.497 217.721 145.38C196.712 177.468 193.858 224.004 203.82 273.827C206.532 287.394 210.127 300.834 214.345 313.817C236.45 310.276 260.156 311.463 281.22 317.11C319.621 327.403 357.501 355.419 357.501 405.654C357.501 435.255 339.111 465.136 307.278 473.815C273.211 483.103 238.854 464.822 213.105 427.541C203.716 413.947 194.443 397.766 185.947 379.89C174.028 392.223 163.08 411.953 158.673 436.118C153.128 466.518 158.514 501.286 183.085 532.253C195.993 548.522 217.742 562.031 240.771 567.349C263.594 572.619 284.147 569.24 298.664 557.154C349.383 514.927 390.709 466.547 426.366 422.952C448.879 390.86 453.195 356.06 445.578 321.265C436.703 280.718 411.425 240.06 378.642 206.356ZM306.296 405.722C306.296 384.769 292.223 370.736 267.284 364.051C256.012 361.03 244.156 360.087 233.095 360.771C240.361 375.935 248.168 389.513 255.897 400.704C275.647 429.298 289.989 427.822 293.247 426.934C298.737 425.437 306.296 418.161 306.296 405.722Z" fill="#20744A"/>
|
<rect width="700" height="700" rx="100" fill="black"/>
|
||||||
|
<path d="M291.495 91.399C333.897 104.892 379.155 135.075 416.229 173.191C453.389 211.394 484.429 259.725 495.708 311.251C497.555 319.693 498.865 328.216 499.586 336.776C509.755 326.554 519.867 317.815 529.89 311.547C540.647 304.821 553.808 299.297 568.641 299.785C584.29 300.299 597.395 307.326 607.747 317.632C632.173 341.947 629.612 372.898 619.872 397.936C610.185 422.833 591.557 447.826 572.732 469.124C553.591 490.78 532.713 510.308 516.779 524.318C508.775 531.355 501.936 537.073 497.07 541.052C494.635 543.043 492.689 544.603 491.334 545.679C490.657 546.217 490.126 546.635 489.756 546.926C489.571 547.071 489.425 547.184 489.321 547.265C489.269 547.305 489.227 547.338 489.196 547.362C489.181 547.374 489.168 547.385 489.157 547.393C489.153 547.397 489.147 547.401 489.144 547.403C489.134 547.4 488.837 547.06 473.001 528.499L489.135 547.411C478.157 555.911 462.033 554.334 453.122 543.89C444.213 533.448 445.887 518.094 456.861 509.592C456.863 509.591 456.865 509.588 456.869 509.586C456.88 509.577 456.902 509.561 456.933 509.536C456.997 509.487 457.101 509.404 457.245 509.292C457.533 509.066 457.979 508.715 458.569 508.247C459.749 507.31 461.506 505.901 463.742 504.073C468.216 500.414 474.589 495.088 482.073 488.508C497.114 475.284 516.315 457.282 533.578 437.75C551.157 417.862 565.26 398.01 571.859 381.048C578.403 364.227 575.681 356.302 570.724 351.367C568.928 349.579 567.744 348.902 567.267 348.676C566.888 348.496 566.811 348.52 566.804 348.52C566.605 348.513 563.971 348.537 557.953 352.3C545.161 360.299 528.815 377.492 506.807 403.867C494.927 418.106 481.871 434.435 467.547 451.957C463.709 457.28 459.503 462.538 454.91 467.717L454.702 467.549C420.808 508.347 380.37 553.856 332.335 593.848C301.853 619.226 262.656 622.597 228.642 614.743C194.834 606.936 162.658 587.448 142.217 561.686C108.054 518.631 100.57 469.801 108.223 427.836C115.56 387.606 137.391 351.005 166.502 331.557C161.248 315.813 156.813 299.49 153.519 283.013C142.593 228.368 143.239 167.031 174.28 119.619C186.922 100.31 205.846 89.1535 227.387 85.2773C248.1 81.5504 270.278 84.648 291.495 91.399ZM378.642 206.356C345.773 172.563 307.463 147.917 275.208 137.654C259.096 132.527 246.171 131.514 236.828 133.195C228.314 134.727 222.227 138.497 217.721 145.38C196.712 177.468 193.858 224.004 203.82 273.827C206.532 287.394 210.127 300.834 214.345 313.817C236.45 310.276 260.156 311.463 281.22 317.11C319.621 327.403 357.501 355.419 357.501 405.654C357.501 435.255 339.111 465.136 307.278 473.815C273.211 483.103 238.854 464.822 213.105 427.541C203.716 413.947 194.443 397.766 185.947 379.89C174.028 392.223 163.08 411.953 158.673 436.118C153.128 466.518 158.514 501.286 183.085 532.253C195.993 548.522 217.742 562.031 240.771 567.349C263.594 572.619 284.147 569.24 298.664 557.154C349.383 514.927 390.709 466.547 426.366 422.952C448.879 390.86 453.195 356.06 445.578 321.265C436.703 280.718 411.425 240.06 378.642 206.356ZM306.296 405.722C306.296 384.769 292.223 370.736 267.284 364.051C256.012 361.03 244.156 360.087 233.095 360.771C240.361 375.935 248.168 389.513 255.897 400.704C275.647 429.298 289.989 427.822 293.247 426.934C298.737 425.437 306.296 418.161 306.296 405.722Z" fill="white"/>
|
||||||
<circle cx="570" cy="570" r="130" fill="#3b82f6"/>
|
<circle cx="570" cy="570" r="130" fill="#3b82f6"/>
|
||||||
</svg>
|
</svg>
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 3.3 KiB After Width: | Height: | Size: 3.3 KiB |
|
Before Width: | Height: | Size: 1.9 KiB After Width: | Height: | Size: 1.7 KiB |
@@ -1,3 +1,4 @@
|
|||||||
<svg width="48" height="48" viewBox="0 0 700 700" fill="none" xmlns="http://www.w3.org/2000/svg">
|
<svg width="48" height="48" viewBox="0 0 700 700" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
<path d="M291.495 91.399C333.897 104.892 379.155 135.075 416.229 173.191C453.389 211.394 484.429 259.725 495.708 311.251C497.555 319.693 498.865 328.216 499.586 336.776C509.755 326.554 519.867 317.815 529.89 311.547C540.647 304.821 553.808 299.297 568.641 299.785C584.29 300.299 597.395 307.326 607.747 317.632C632.173 341.947 629.612 372.898 619.872 397.936C610.185 422.833 591.557 447.826 572.732 469.124C553.591 490.78 532.713 510.308 516.779 524.318C508.775 531.355 501.936 537.073 497.07 541.052C494.635 543.043 492.689 544.603 491.334 545.679C490.657 546.217 490.126 546.635 489.756 546.926C489.571 547.071 489.425 547.184 489.321 547.265C489.269 547.305 489.227 547.338 489.196 547.362C489.181 547.374 489.168 547.385 489.157 547.393C489.153 547.397 489.147 547.401 489.144 547.403C489.134 547.4 488.837 547.06 473.001 528.499L489.135 547.411C478.157 555.911 462.033 554.334 453.122 543.89C444.213 533.448 445.887 518.094 456.861 509.592C456.863 509.591 456.865 509.588 456.869 509.586C456.88 509.577 456.902 509.561 456.933 509.536C456.997 509.487 457.101 509.404 457.245 509.292C457.533 509.066 457.979 508.715 458.569 508.247C459.749 507.31 461.506 505.901 463.742 504.073C468.216 500.414 474.589 495.088 482.073 488.508C497.114 475.284 516.315 457.282 533.578 437.75C551.157 417.862 565.26 398.01 571.859 381.048C578.403 364.227 575.681 356.302 570.724 351.367C568.928 349.579 567.744 348.902 567.267 348.676C566.888 348.496 566.811 348.52 566.804 348.52C566.605 348.513 563.971 348.537 557.953 352.3C545.161 360.299 528.815 377.492 506.807 403.867C494.927 418.106 481.871 434.435 467.547 451.957C463.709 457.28 459.503 462.538 454.91 467.717L454.702 467.549C420.808 508.347 380.37 553.856 332.335 593.848C301.853 619.226 262.656 622.597 228.642 614.743C194.834 606.936 162.658 587.448 142.217 561.686C108.054 518.631 100.57 469.801 108.223 427.836C115.56 387.606 137.391 351.005 166.502 331.557C161.248 315.813 156.813 299.49 153.519 283.013C142.593 228.368 143.239 167.031 174.28 119.619C186.922 100.31 205.846 89.1535 227.387 85.2773C248.1 81.5504 270.278 84.648 291.495 91.399ZM378.642 206.356C345.773 172.563 307.463 147.917 275.208 137.654C259.096 132.527 246.171 131.514 236.828 133.195C228.314 134.727 222.227 138.497 217.721 145.38C196.712 177.468 193.858 224.004 203.82 273.827C206.532 287.394 210.127 300.834 214.345 313.817C236.45 310.276 260.156 311.463 281.22 317.11C319.621 327.403 357.501 355.419 357.501 405.654C357.501 435.255 339.111 465.136 307.278 473.815C273.211 483.103 238.854 464.822 213.105 427.541C203.716 413.947 194.443 397.766 185.947 379.89C174.028 392.223 163.08 411.953 158.673 436.118C153.128 466.518 158.514 501.286 183.085 532.253C195.993 548.522 217.742 562.031 240.771 567.349C263.594 572.619 284.147 569.24 298.664 557.154C349.383 514.927 390.709 466.547 426.366 422.952C448.879 390.86 453.195 356.06 445.578 321.265C436.703 280.718 411.425 240.06 378.642 206.356ZM306.296 405.722C306.296 384.769 292.223 370.736 267.284 364.051C256.012 361.03 244.156 360.087 233.095 360.771C240.361 375.935 248.168 389.513 255.897 400.704C275.647 429.298 289.989 427.822 293.247 426.934C298.737 425.437 306.296 418.161 306.296 405.722Z" fill="#20744A"/>
|
<rect width="700" height="700" rx="100" fill="black"/>
|
||||||
|
<path d="M291.495 91.399C333.897 104.892 379.155 135.075 416.229 173.191C453.389 211.394 484.429 259.725 495.708 311.251C497.555 319.693 498.865 328.216 499.586 336.776C509.755 326.554 519.867 317.815 529.89 311.547C540.647 304.821 553.808 299.297 568.641 299.785C584.29 300.299 597.395 307.326 607.747 317.632C632.173 341.947 629.612 372.898 619.872 397.936C610.185 422.833 591.557 447.826 572.732 469.124C553.591 490.78 532.713 510.308 516.779 524.318C508.775 531.355 501.936 537.073 497.07 541.052C494.635 543.043 492.689 544.603 491.334 545.679C490.657 546.217 490.126 546.635 489.756 546.926C489.571 547.071 489.425 547.184 489.321 547.265C489.269 547.305 489.227 547.338 489.196 547.362C489.181 547.374 489.168 547.385 489.157 547.393C489.153 547.397 489.147 547.401 489.144 547.403C489.134 547.4 488.837 547.06 473.001 528.499L489.135 547.411C478.157 555.911 462.033 554.334 453.122 543.89C444.213 533.448 445.887 518.094 456.861 509.592C456.863 509.591 456.865 509.588 456.869 509.586C456.88 509.577 456.902 509.561 456.933 509.536C456.997 509.487 457.101 509.404 457.245 509.292C457.533 509.066 457.979 508.715 458.569 508.247C459.749 507.31 461.506 505.901 463.742 504.073C468.216 500.414 474.589 495.088 482.073 488.508C497.114 475.284 516.315 457.282 533.578 437.75C551.157 417.862 565.26 398.01 571.859 381.048C578.403 364.227 575.681 356.302 570.724 351.367C568.928 349.579 567.744 348.902 567.267 348.676C566.888 348.496 566.811 348.52 566.804 348.52C566.605 348.513 563.971 348.537 557.953 352.3C545.161 360.299 528.815 377.492 506.807 403.867C494.927 418.106 481.871 434.435 467.547 451.957C463.709 457.28 459.503 462.538 454.91 467.717L454.702 467.549C420.808 508.347 380.37 553.856 332.335 593.848C301.853 619.226 262.656 622.597 228.642 614.743C194.834 606.936 162.658 587.448 142.217 561.686C108.054 518.631 100.57 469.801 108.223 427.836C115.56 387.606 137.391 351.005 166.502 331.557C161.248 315.813 156.813 299.49 153.519 283.013C142.593 228.368 143.239 167.031 174.28 119.619C186.922 100.31 205.846 89.1535 227.387 85.2773C248.1 81.5504 270.278 84.648 291.495 91.399ZM378.642 206.356C345.773 172.563 307.463 147.917 275.208 137.654C259.096 132.527 246.171 131.514 236.828 133.195C228.314 134.727 222.227 138.497 217.721 145.38C196.712 177.468 193.858 224.004 203.82 273.827C206.532 287.394 210.127 300.834 214.345 313.817C236.45 310.276 260.156 311.463 281.22 317.11C319.621 327.403 357.501 355.419 357.501 405.654C357.501 435.255 339.111 465.136 307.278 473.815C273.211 483.103 238.854 464.822 213.105 427.541C203.716 413.947 194.443 397.766 185.947 379.89C174.028 392.223 163.08 411.953 158.673 436.118C153.128 466.518 158.514 501.286 183.085 532.253C195.993 548.522 217.742 562.031 240.771 567.349C263.594 572.619 284.147 569.24 298.664 557.154C349.383 514.927 390.709 466.547 426.366 422.952C448.879 390.86 453.195 356.06 445.578 321.265C436.703 280.718 411.425 240.06 378.642 206.356ZM306.296 405.722C306.296 384.769 292.223 370.736 267.284 364.051C256.012 361.03 244.156 360.087 233.095 360.771C240.361 375.935 248.168 389.513 255.897 400.704C275.647 429.298 289.989 427.822 293.247 426.934C298.737 425.437 306.296 418.161 306.296 405.722Z" fill="white"/>
|
||||||
</svg>
|
</svg>
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 3.2 KiB After Width: | Height: | Size: 3.3 KiB |
|
Before Width: | Height: | Size: 1.1 KiB After Width: | Height: | Size: 1.7 KiB |
|
Before Width: | Height: | Size: 60 KiB After Width: | Height: | Size: 57 KiB |
|
Before Width: | Height: | Size: 9.8 KiB After Width: | Height: | Size: 7.9 KiB |
@@ -2,7 +2,7 @@ import { test, expect, type Page } from '@playwright/test';
|
|||||||
|
|
||||||
const consoleEntries = new WeakMap<Page, string[]>();
|
const consoleEntries = new WeakMap<Page, string[]>();
|
||||||
|
|
||||||
test.beforeEach(({ page }) => {
|
test.beforeEach(async ({ page }) => {
|
||||||
const entries: string[] = [];
|
const entries: string[] = [];
|
||||||
consoleEntries.set(page, entries);
|
consoleEntries.set(page, entries);
|
||||||
|
|
||||||
@@ -13,6 +13,21 @@ test.beforeEach(({ page }) => {
|
|||||||
page.on('pageerror', (error) => {
|
page.on('pageerror', (error) => {
|
||||||
entries.push(`[pageerror] ${error.message}`);
|
entries.push(`[pageerror] ${error.message}`);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Set up test daemon connection if available
|
||||||
|
const daemonPort = process.env.E2E_DAEMON_PORT;
|
||||||
|
if (daemonPort) {
|
||||||
|
const testDaemon = {
|
||||||
|
id: 'e2e-test-daemon',
|
||||||
|
label: 'localhost',
|
||||||
|
wsUrl: `ws://localhost:${daemonPort}/ws`,
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
updatedAt: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
await page.addInitScript((daemon) => {
|
||||||
|
localStorage.setItem('@paseo:daemon-registry', JSON.stringify([daemon]));
|
||||||
|
}, testDaemon);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
test.afterEach(async ({ page }, testInfo) => {
|
test.afterEach(async ({ page }, testInfo) => {
|
||||||
|
|||||||
88
packages/app/e2e/global-setup.ts
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
import { spawn, type ChildProcess, execSync } from 'node:child_process';
|
||||||
|
import { mkdtemp, rm } from 'node:fs/promises';
|
||||||
|
import { tmpdir } from 'node:os';
|
||||||
|
import path from 'node:path';
|
||||||
|
import net from 'node:net';
|
||||||
|
|
||||||
|
async function getAvailablePort(): Promise<number> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const server = net.createServer();
|
||||||
|
server.once('error', reject);
|
||||||
|
server.listen(0, () => {
|
||||||
|
const address = server.address();
|
||||||
|
if (!address || typeof address === 'string') {
|
||||||
|
server.close(() => reject(new Error('Failed to acquire port')));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
server.close(() => resolve(address.port));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function waitForServer(port: number, timeout = 15000): Promise<void> {
|
||||||
|
const start = Date.now();
|
||||||
|
while (Date.now() - start < timeout) {
|
||||||
|
try {
|
||||||
|
await new Promise<void>((resolve, reject) => {
|
||||||
|
const socket = net.connect(port, 'localhost', () => {
|
||||||
|
socket.end();
|
||||||
|
resolve();
|
||||||
|
});
|
||||||
|
socket.on('error', reject);
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
} catch {
|
||||||
|
await new Promise((r) => setTimeout(r, 100));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw new Error(`Server did not start on port ${port} within ${timeout}ms`);
|
||||||
|
}
|
||||||
|
|
||||||
|
let daemonProcess: ChildProcess | null = null;
|
||||||
|
let paseoHome: string | null = null;
|
||||||
|
|
||||||
|
export default async function globalSetup() {
|
||||||
|
const port = await getAvailablePort();
|
||||||
|
paseoHome = await mkdtemp(path.join(tmpdir(), 'paseo-e2e-home-'));
|
||||||
|
|
||||||
|
const serverDir = path.resolve(__dirname, '../../..', 'packages/server');
|
||||||
|
const tsxBin = execSync('which tsx').toString().trim();
|
||||||
|
|
||||||
|
daemonProcess = spawn(tsxBin, ['src/server/index.ts'], {
|
||||||
|
cwd: serverDir,
|
||||||
|
env: {
|
||||||
|
...process.env,
|
||||||
|
PASEO_HOME: paseoHome,
|
||||||
|
PASEO_LISTEN: `0.0.0.0:${port}`,
|
||||||
|
PASEO_CORS_ORIGINS: 'http://localhost:8081',
|
||||||
|
NODE_ENV: 'development',
|
||||||
|
},
|
||||||
|
stdio: ['ignore', 'pipe', 'pipe'],
|
||||||
|
detached: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
daemonProcess.stdout?.on('data', (data: Buffer) => {
|
||||||
|
console.log(`[daemon] ${data.toString().trim()}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
daemonProcess.stderr?.on('data', (data: Buffer) => {
|
||||||
|
console.error(`[daemon] ${data.toString().trim()}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitForServer(port);
|
||||||
|
|
||||||
|
process.env.E2E_DAEMON_PORT = String(port);
|
||||||
|
console.log(`[e2e] Test daemon started on port ${port}, home: ${paseoHome}`);
|
||||||
|
|
||||||
|
return async () => {
|
||||||
|
if (daemonProcess) {
|
||||||
|
daemonProcess.kill('SIGTERM');
|
||||||
|
daemonProcess = null;
|
||||||
|
}
|
||||||
|
if (paseoHome) {
|
||||||
|
await rm(paseoHome, { recursive: true, force: true });
|
||||||
|
paseoHome = null;
|
||||||
|
}
|
||||||
|
console.log('[e2e] Test daemon stopped');
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -59,3 +59,105 @@ export const createAgent = async (page: Page, message: string) => {
|
|||||||
await page.waitForURL(/\/agent\//);
|
await page.waitForURL(/\/agent\//);
|
||||||
await expect(page.getByText(message, { exact: true })).toBeVisible();
|
await expect(page.getByText(message, { exact: true })).toBeVisible();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export interface AgentConfig {
|
||||||
|
directory: string;
|
||||||
|
provider?: string;
|
||||||
|
model?: string;
|
||||||
|
mode?: string;
|
||||||
|
prompt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const selectProvider = async (page: Page, provider: string) => {
|
||||||
|
const providerLabel = page.getByText('PROVIDER', { exact: true }).first();
|
||||||
|
await expect(providerLabel).toBeVisible();
|
||||||
|
await providerLabel.click();
|
||||||
|
|
||||||
|
const option = page.getByText(provider, { exact: true }).first();
|
||||||
|
await expect(option).toBeVisible();
|
||||||
|
await option.click();
|
||||||
|
};
|
||||||
|
|
||||||
|
export const selectModel = async (page: Page, model: string) => {
|
||||||
|
const modelLabel = page.getByText('MODEL', { exact: true }).first();
|
||||||
|
await expect(modelLabel).toBeVisible();
|
||||||
|
await modelLabel.click();
|
||||||
|
|
||||||
|
// Wait for the model dropdown to open
|
||||||
|
const searchInput = page.getByRole('textbox', { name: /search model/i });
|
||||||
|
await expect(searchInput).toBeVisible({ timeout: 10000 });
|
||||||
|
|
||||||
|
// Type to search/filter models
|
||||||
|
await searchInput.fill(model);
|
||||||
|
|
||||||
|
// Wait for a matching option to appear (partial match via regex)
|
||||||
|
const option = page.getByText(new RegExp(model, 'i')).first();
|
||||||
|
await expect(option).toBeVisible({ timeout: 30000 });
|
||||||
|
await option.click();
|
||||||
|
|
||||||
|
// Wait for dropdown to close
|
||||||
|
await expect(searchInput).not.toBeVisible({ timeout: 5000 });
|
||||||
|
};
|
||||||
|
|
||||||
|
export const selectMode = async (page: Page, mode: string) => {
|
||||||
|
const modeLabel = page.getByText('MODE', { exact: true }).first();
|
||||||
|
await expect(modeLabel).toBeVisible();
|
||||||
|
await modeLabel.click();
|
||||||
|
|
||||||
|
// Wait for the mode dropdown to open
|
||||||
|
const searchInput = page.getByRole('textbox', { name: /search mode/i });
|
||||||
|
await expect(searchInput).toBeVisible({ timeout: 10000 });
|
||||||
|
|
||||||
|
// Type to filter modes
|
||||||
|
await searchInput.fill(mode);
|
||||||
|
|
||||||
|
// Click the matching option (use last() since the field label also contains the mode text)
|
||||||
|
const option = page.getByText(mode, { exact: true }).last();
|
||||||
|
await expect(option).toBeVisible();
|
||||||
|
await option.click();
|
||||||
|
|
||||||
|
// Wait for dropdown to close
|
||||||
|
await expect(searchInput).not.toBeVisible({ timeout: 5000 });
|
||||||
|
};
|
||||||
|
|
||||||
|
export const createAgentWithConfig = async (page: Page, config: AgentConfig) => {
|
||||||
|
await gotoHome(page);
|
||||||
|
await ensureHostSelected(page);
|
||||||
|
await setWorkingDirectory(page, config.directory);
|
||||||
|
|
||||||
|
if (config.provider) {
|
||||||
|
await selectProvider(page, config.provider);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (config.model) {
|
||||||
|
await selectModel(page, config.model);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (config.mode) {
|
||||||
|
await selectMode(page, config.mode);
|
||||||
|
}
|
||||||
|
|
||||||
|
await createAgent(page, config.prompt);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const waitForPermissionPrompt = async (page: Page, timeout = 30000) => {
|
||||||
|
const promptText = page.getByText('How would you like to proceed?').first();
|
||||||
|
await expect(promptText).toBeVisible({ timeout });
|
||||||
|
};
|
||||||
|
|
||||||
|
export const allowPermission = async (page: Page) => {
|
||||||
|
const allowButton = page.getByText('Allow', { exact: true }).first();
|
||||||
|
await expect(allowButton).toBeVisible({ timeout: 5000 });
|
||||||
|
await allowButton.click();
|
||||||
|
};
|
||||||
|
|
||||||
|
export const denyPermission = async (page: Page) => {
|
||||||
|
const denyButton = page.getByText('Deny', { exact: true }).first();
|
||||||
|
await expect(denyButton).toBeVisible({ timeout: 5000 });
|
||||||
|
await denyButton.click();
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function waitForAgentIdle(page: Page, timeout = 30000) {
|
||||||
|
const stopButton = page.getByRole('button', { name: /stop|cancel/i });
|
||||||
|
await expect(stopButton).not.toBeVisible({ timeout });
|
||||||
|
}
|
||||||
|
|||||||
@@ -13,6 +13,8 @@ export const createTempGitRepo = async (prefix = 'paseo-e2e-'): Promise<TempRepo
|
|||||||
|
|
||||||
execSync('git init', { cwd: repoPath, stdio: 'ignore' });
|
execSync('git init', { cwd: repoPath, stdio: 'ignore' });
|
||||||
await writeFile(path.join(repoPath, 'README.md'), '# Temp Repo\n');
|
await writeFile(path.join(repoPath, 'README.md'), '# Temp Repo\n');
|
||||||
|
execSync('git add README.md', { cwd: repoPath, stdio: 'ignore' });
|
||||||
|
execSync('git commit -m "Initial commit"', { cwd: repoPath, stdio: 'ignore' });
|
||||||
|
|
||||||
return {
|
return {
|
||||||
path: repoPath,
|
path: repoPath,
|
||||||
|
|||||||
68
packages/app/e2e/permission-prompt.spec.ts
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
import { existsSync } from 'node:fs';
|
||||||
|
import path from 'node:path';
|
||||||
|
import { test, expect } from './fixtures';
|
||||||
|
import {
|
||||||
|
createAgentWithConfig,
|
||||||
|
waitForPermissionPrompt,
|
||||||
|
allowPermission,
|
||||||
|
denyPermission,
|
||||||
|
waitForAgentIdle,
|
||||||
|
} from './helpers/app';
|
||||||
|
import { createTempGitRepo } from './helpers/workspace';
|
||||||
|
|
||||||
|
const FILE_CONTENT = 'Hello from permission test';
|
||||||
|
|
||||||
|
test.describe('permission prompts', () => {
|
||||||
|
test('allow permission creates the file', async ({ page }) => {
|
||||||
|
const repo = await createTempGitRepo();
|
||||||
|
const uniqueFilename = `test-allow-${Date.now()}.txt`;
|
||||||
|
const filePath = path.join(repo.path, uniqueFilename);
|
||||||
|
const prompt = `Create a file named "${uniqueFilename}" with the content "${FILE_CONTENT}". Do not add any extra content.`;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await createAgentWithConfig(page, {
|
||||||
|
directory: repo.path,
|
||||||
|
model: 'haiku',
|
||||||
|
mode: 'Always Ask',
|
||||||
|
prompt,
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitForPermissionPrompt(page, 30000);
|
||||||
|
await allowPermission(page);
|
||||||
|
|
||||||
|
// Wait for file to be created
|
||||||
|
await expect
|
||||||
|
.poll(() => existsSync(filePath), {
|
||||||
|
message: `File ${filePath} should exist after allowing permission`,
|
||||||
|
timeout: 10000,
|
||||||
|
})
|
||||||
|
.toBe(true);
|
||||||
|
} finally {
|
||||||
|
await repo.cleanup();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('deny permission does not create the file', async ({ page }) => {
|
||||||
|
const repo = await createTempGitRepo();
|
||||||
|
const uniqueFilename = `test-deny-${Date.now()}.txt`;
|
||||||
|
const filePath = path.join(repo.path, uniqueFilename);
|
||||||
|
const prompt = `Create a file named "${uniqueFilename}" with the content "${FILE_CONTENT}". Do not add any extra content.`;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await createAgentWithConfig(page, {
|
||||||
|
directory: repo.path,
|
||||||
|
model: 'haiku',
|
||||||
|
mode: 'Always Ask',
|
||||||
|
prompt,
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitForPermissionPrompt(page, 30000);
|
||||||
|
await denyPermission(page);
|
||||||
|
await waitForAgentIdle(page);
|
||||||
|
|
||||||
|
expect(existsSync(filePath)).toBe(false);
|
||||||
|
} finally {
|
||||||
|
await repo.cleanup();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -4,6 +4,7 @@ const baseURL = process.env.E2E_BASE_URL ?? 'http://localhost:8081';
|
|||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
testDir: './e2e',
|
testDir: './e2e',
|
||||||
|
globalSetup: './e2e/global-setup.ts',
|
||||||
timeout: 60_000,
|
timeout: 60_000,
|
||||||
expect: {
|
expect: {
|
||||||
timeout: 10_000,
|
timeout: 10_000,
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
|||||||
import { useState, useEffect, type ReactNode, useMemo } from "react";
|
import { useState, useEffect, type ReactNode, useMemo } from "react";
|
||||||
import { Platform } from "react-native";
|
import { Platform } from "react-native";
|
||||||
import { SlidingSidebar } from "@/components/sliding-sidebar";
|
import { SlidingSidebar } from "@/components/sliding-sidebar";
|
||||||
|
import { DownloadToast } from "@/components/download-toast";
|
||||||
import { usePanelStore } from "@/stores/panel-store";
|
import { usePanelStore } from "@/stores/panel-store";
|
||||||
import { runOnJS, interpolate, Extrapolation, useSharedValue } from "react-native-reanimated";
|
import { runOnJS, interpolate, Extrapolation, useSharedValue } from "react-native-reanimated";
|
||||||
import {
|
import {
|
||||||
@@ -158,6 +159,7 @@ function AppContainer({ children, selectedAgentId }: AppContainerProps) {
|
|||||||
<View style={{ flex: 1 }}>{children}</View>
|
<View style={{ flex: 1 }}>{children}</View>
|
||||||
</View>
|
</View>
|
||||||
{isMobile && <SlidingSidebar selectedAgentId={selectedAgentId} />}
|
{isMobile && <SlidingSidebar selectedAgentId={selectedAgentId} />}
|
||||||
|
<DownloadToast />
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -16,7 +16,6 @@ import Markdown from "react-native-markdown-display";
|
|||||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||||
import { useMutation } from "@tanstack/react-query";
|
import { useMutation } from "@tanstack/react-query";
|
||||||
import { Fonts } from "@/constants/theme";
|
|
||||||
import Animated, {
|
import Animated, {
|
||||||
FadeIn,
|
FadeIn,
|
||||||
FadeOut,
|
FadeOut,
|
||||||
@@ -41,18 +40,14 @@ import {
|
|||||||
MessageOuterSpacingProvider,
|
MessageOuterSpacingProvider,
|
||||||
type InlinePathTarget,
|
type InlinePathTarget,
|
||||||
} from "./message";
|
} from "./message";
|
||||||
import { DiffViewer } from "./diff-viewer";
|
|
||||||
import type { StreamItem } from "@/types/stream";
|
import type { StreamItem } from "@/types/stream";
|
||||||
import type { PendingPermission } from "@/types/shared";
|
import type { PendingPermission } from "@/types/shared";
|
||||||
import type { AgentPermissionResponse } from "@server/server/agent/agent-sdk-types";
|
import type { AgentPermissionResponse } from "@server/server/agent/agent-sdk-types";
|
||||||
import type { Agent } from "@/contexts/session-context";
|
import type { Agent } from "@/contexts/session-context";
|
||||||
import { useSessionStore } from "@/stores/session-store";
|
import { useSessionStore } from "@/stores/session-store";
|
||||||
import type { DaemonClientV2 } from "@server/client/daemon-client-v2";
|
import type { DaemonClientV2 } from "@server/client/daemon-client-v2";
|
||||||
import {
|
import { parseToolCallDisplay } from "@/utils/tool-call-parsers";
|
||||||
extractCommandDetails,
|
import { ToolCallDetailsContent } from "./tool-call-details";
|
||||||
extractEditEntries,
|
|
||||||
extractReadEntries,
|
|
||||||
} from "@/utils/tool-call-parsers";
|
|
||||||
import { ToolCallSheetProvider } from "./tool-call-sheet";
|
import { ToolCallSheetProvider } from "./tool-call-sheet";
|
||||||
import { createMarkdownStyles } from "@/styles/markdown-styles";
|
import { createMarkdownStyles } from "@/styles/markdown-styles";
|
||||||
import { MAX_CONTENT_WIDTH } from "@/constants/layout";
|
import { MAX_CONTENT_WIDTH } from "@/constants/layout";
|
||||||
@@ -762,9 +757,6 @@ function PermissionRequestCard({
|
|||||||
const { request } = permission;
|
const { request } = permission;
|
||||||
const title = request.title ?? request.name ?? "Permission Required";
|
const title = request.title ?? request.name ?? "Permission Required";
|
||||||
const description = request.description ?? "";
|
const description = request.description ?? "";
|
||||||
const inputPreview = request.input
|
|
||||||
? JSON.stringify(request.input, null, 2)
|
|
||||||
: null;
|
|
||||||
|
|
||||||
const planMarkdown = useMemo(() => {
|
const planMarkdown = useMemo(() => {
|
||||||
if (!request) {
|
if (!request) {
|
||||||
@@ -784,19 +776,9 @@ function PermissionRequestCard({
|
|||||||
return undefined;
|
return undefined;
|
||||||
}, [request]);
|
}, [request]);
|
||||||
|
|
||||||
const editEntries = useMemo(
|
const toolCallDisplay = useMemo(
|
||||||
() => extractEditEntries(request.input, request.metadata),
|
() => parseToolCallDisplay(request.input, null),
|
||||||
[request]
|
[request.input]
|
||||||
);
|
|
||||||
|
|
||||||
const readEntries = useMemo(
|
|
||||||
() => extractReadEntries(request.input, request.metadata),
|
|
||||||
[request]
|
|
||||||
);
|
|
||||||
|
|
||||||
const commandDetails = useMemo(
|
|
||||||
() => extractCommandDetails(request.input, request.metadata),
|
|
||||||
[request]
|
|
||||||
);
|
);
|
||||||
|
|
||||||
const markdownStyles = useMemo(() => createMarkdownStyles(theme), [theme]);
|
const markdownStyles = useMemo(() => createMarkdownStyles(theme), [theme]);
|
||||||
@@ -1016,187 +998,7 @@ function PermissionRequestCard({
|
|||||||
</View>
|
</View>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{commandDetails ? (
|
<ToolCallDetailsContent display={toolCallDisplay} maxHeight={200} />
|
||||||
<View style={permissionStyles.section}>
|
|
||||||
<Text
|
|
||||||
style={[
|
|
||||||
permissionStyles.sectionTitle,
|
|
||||||
{ color: theme.colors.foregroundMuted },
|
|
||||||
]}
|
|
||||||
>
|
|
||||||
Command
|
|
||||||
</Text>
|
|
||||||
{commandDetails.command ? (
|
|
||||||
<View style={permissionStyles.metadataRow}>
|
|
||||||
<Text
|
|
||||||
style={[
|
|
||||||
permissionStyles.metadataLabel,
|
|
||||||
{ color: theme.colors.foregroundMuted },
|
|
||||||
]}
|
|
||||||
>
|
|
||||||
Command
|
|
||||||
</Text>
|
|
||||||
<Text
|
|
||||||
style={[
|
|
||||||
permissionStyles.metadataValue,
|
|
||||||
{ color: theme.colors.foreground },
|
|
||||||
]}
|
|
||||||
>
|
|
||||||
{commandDetails.command}
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
) : null}
|
|
||||||
{commandDetails.cwd ? (
|
|
||||||
<View style={permissionStyles.metadataRow}>
|
|
||||||
<Text
|
|
||||||
style={[
|
|
||||||
permissionStyles.metadataLabel,
|
|
||||||
{ color: theme.colors.foregroundMuted },
|
|
||||||
]}
|
|
||||||
>
|
|
||||||
Directory
|
|
||||||
</Text>
|
|
||||||
<Text
|
|
||||||
style={[
|
|
||||||
permissionStyles.metadataValue,
|
|
||||||
{ color: theme.colors.foreground },
|
|
||||||
]}
|
|
||||||
>
|
|
||||||
{commandDetails.cwd}
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
) : null}
|
|
||||||
</View>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
{editEntries.length > 0 ? (
|
|
||||||
<View style={permissionStyles.section}>
|
|
||||||
<Text
|
|
||||||
style={[
|
|
||||||
permissionStyles.sectionTitle,
|
|
||||||
{ color: theme.colors.foregroundMuted },
|
|
||||||
]}
|
|
||||||
>
|
|
||||||
Proposed Changes
|
|
||||||
</Text>
|
|
||||||
{editEntries.map((entry, index) => (
|
|
||||||
<View
|
|
||||||
key={`${entry.filePath ?? "change"}-${index}`}
|
|
||||||
style={permissionStyles.diffSection}
|
|
||||||
>
|
|
||||||
{entry.filePath ? (
|
|
||||||
<View
|
|
||||||
style={[
|
|
||||||
permissionStyles.fileBadge,
|
|
||||||
{
|
|
||||||
borderColor: theme.colors.border,
|
|
||||||
backgroundColor: theme.colors.surface2,
|
|
||||||
},
|
|
||||||
]}
|
|
||||||
>
|
|
||||||
<Text
|
|
||||||
style={[
|
|
||||||
permissionStyles.fileBadgeText,
|
|
||||||
{ color: theme.colors.foreground },
|
|
||||||
]}
|
|
||||||
>
|
|
||||||
{entry.filePath}
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
) : null}
|
|
||||||
<View
|
|
||||||
style={[
|
|
||||||
permissionStyles.diffWrapper,
|
|
||||||
{
|
|
||||||
borderColor: theme.colors.border,
|
|
||||||
backgroundColor: theme.colors.surface2,
|
|
||||||
},
|
|
||||||
]}
|
|
||||||
>
|
|
||||||
<DiffViewer diffLines={entry.diffLines} maxHeight={200} />
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
))}
|
|
||||||
</View>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
{readEntries.length > 0 ? (
|
|
||||||
<View style={permissionStyles.section}>
|
|
||||||
<Text
|
|
||||||
style={[
|
|
||||||
permissionStyles.sectionTitle,
|
|
||||||
{ color: theme.colors.foregroundMuted },
|
|
||||||
]}
|
|
||||||
>
|
|
||||||
File Content
|
|
||||||
</Text>
|
|
||||||
{readEntries.map((entry, index) => (
|
|
||||||
<View
|
|
||||||
key={`${entry.filePath ?? "content"}-${index}`}
|
|
||||||
style={[
|
|
||||||
permissionStyles.contentCard,
|
|
||||||
{
|
|
||||||
backgroundColor: theme.colors.surface0,
|
|
||||||
borderColor: theme.colors.border,
|
|
||||||
},
|
|
||||||
]}
|
|
||||||
>
|
|
||||||
{entry.filePath ? (
|
|
||||||
<Text
|
|
||||||
style={[
|
|
||||||
permissionStyles.metadataLabel,
|
|
||||||
{
|
|
||||||
color: theme.colors.foregroundMuted,
|
|
||||||
marginBottom: theme.spacing[1],
|
|
||||||
},
|
|
||||||
]}
|
|
||||||
>
|
|
||||||
{entry.filePath}
|
|
||||||
</Text>
|
|
||||||
) : null}
|
|
||||||
<Text
|
|
||||||
style={[
|
|
||||||
permissionStyles.rawContentText,
|
|
||||||
{ color: theme.colors.foreground },
|
|
||||||
]}
|
|
||||||
>
|
|
||||||
{entry.content}
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
))}
|
|
||||||
</View>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
{inputPreview ? (
|
|
||||||
<View style={permissionStyles.section}>
|
|
||||||
<Text
|
|
||||||
style={[
|
|
||||||
permissionStyles.sectionTitle,
|
|
||||||
{ color: theme.colors.foregroundMuted },
|
|
||||||
]}
|
|
||||||
>
|
|
||||||
Raw Request
|
|
||||||
</Text>
|
|
||||||
<View
|
|
||||||
style={[
|
|
||||||
permissionStyles.contentCard,
|
|
||||||
{
|
|
||||||
backgroundColor: theme.colors.surface0,
|
|
||||||
borderColor: theme.colors.border,
|
|
||||||
},
|
|
||||||
]}
|
|
||||||
>
|
|
||||||
<Text
|
|
||||||
style={[
|
|
||||||
permissionStyles.rawContentText,
|
|
||||||
{ color: theme.colors.foreground },
|
|
||||||
]}
|
|
||||||
>
|
|
||||||
{inputPreview}
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
<Text
|
<Text
|
||||||
style={[
|
style={[
|
||||||
@@ -1401,42 +1203,6 @@ const permissionStyles = StyleSheet.create((theme) => ({
|
|||||||
flexShrink: 1,
|
flexShrink: 1,
|
||||||
minWidth: 0,
|
minWidth: 0,
|
||||||
},
|
},
|
||||||
metadataRow: {
|
|
||||||
marginBottom: theme.spacing[2],
|
|
||||||
},
|
|
||||||
metadataLabel: {
|
|
||||||
fontSize: theme.fontSize.xs,
|
|
||||||
textTransform: "uppercase" as const,
|
|
||||||
letterSpacing: 0.5,
|
|
||||||
},
|
|
||||||
metadataValue: {
|
|
||||||
fontFamily: Fonts.mono,
|
|
||||||
fontSize: theme.fontSize.sm,
|
|
||||||
},
|
|
||||||
diffSection: {
|
|
||||||
gap: theme.spacing[2],
|
|
||||||
},
|
|
||||||
fileBadge: {
|
|
||||||
alignSelf: "flex-start",
|
|
||||||
paddingHorizontal: theme.spacing[2],
|
|
||||||
paddingVertical: theme.spacing[1],
|
|
||||||
borderRadius: theme.borderRadius.lg,
|
|
||||||
borderWidth: theme.borderWidth[1],
|
|
||||||
},
|
|
||||||
fileBadgeText: {
|
|
||||||
fontFamily: Fonts.mono,
|
|
||||||
fontSize: theme.fontSize.xs,
|
|
||||||
},
|
|
||||||
diffWrapper: {
|
|
||||||
borderRadius: theme.borderRadius.lg,
|
|
||||||
borderWidth: theme.borderWidth[1],
|
|
||||||
overflow: "hidden",
|
|
||||||
},
|
|
||||||
rawContentText: {
|
|
||||||
fontFamily: Fonts.mono,
|
|
||||||
fontSize: theme.fontSize.sm,
|
|
||||||
lineHeight: 20,
|
|
||||||
},
|
|
||||||
question: {
|
question: {
|
||||||
fontSize: theme.fontSize.sm,
|
fontSize: theme.fontSize.sm,
|
||||||
marginTop: theme.spacing[2],
|
marginTop: theme.spacing[2],
|
||||||
|
|||||||
141
packages/app/src/components/download-toast.tsx
Normal file
@@ -0,0 +1,141 @@
|
|||||||
|
import { useEffect, useRef } from "react";
|
||||||
|
import { ActivityIndicator, Pressable, Text, View } from "react-native";
|
||||||
|
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||||
|
import { Check, X, XCircle } from "lucide-react-native";
|
||||||
|
import { useDownloadStore, formatSpeed, formatEta } from "@/stores/download-store";
|
||||||
|
|
||||||
|
const AUTO_DISMISS_DELAY = 3000;
|
||||||
|
|
||||||
|
export function DownloadToast() {
|
||||||
|
const { theme } = useUnistyles();
|
||||||
|
const downloads = useDownloadStore((state) => state.downloads);
|
||||||
|
const activeDownloadId = useDownloadStore((state) => state.activeDownloadId);
|
||||||
|
const dismissDownload = useDownloadStore((state) => state.dismissDownload);
|
||||||
|
const dismissTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
|
||||||
|
const activeDownload = activeDownloadId ? downloads.get(activeDownloadId) : null;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (dismissTimeoutRef.current) {
|
||||||
|
clearTimeout(dismissTimeoutRef.current);
|
||||||
|
dismissTimeoutRef.current = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (activeDownload && activeDownload.status !== "downloading") {
|
||||||
|
dismissTimeoutRef.current = setTimeout(() => {
|
||||||
|
dismissDownload(activeDownload.id);
|
||||||
|
}, AUTO_DISMISS_DELAY);
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
if (dismissTimeoutRef.current) {
|
||||||
|
clearTimeout(dismissTimeoutRef.current);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, [activeDownload, dismissDownload]);
|
||||||
|
|
||||||
|
if (!activeDownload) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={styles.container} pointerEvents="box-none">
|
||||||
|
<View style={styles.toast}>
|
||||||
|
{activeDownload.status === "downloading" ? (
|
||||||
|
<ActivityIndicator size="small" color={theme.colors.foreground} />
|
||||||
|
) : activeDownload.status === "complete" ? (
|
||||||
|
<Check size={18} color={theme.colors.primary} />
|
||||||
|
) : (
|
||||||
|
<XCircle size={18} color={theme.colors.destructive} />
|
||||||
|
)}
|
||||||
|
<View style={styles.textContainer}>
|
||||||
|
<Text style={styles.fileName} numberOfLines={1}>
|
||||||
|
{activeDownload.fileName}
|
||||||
|
</Text>
|
||||||
|
<Text style={styles.status}>
|
||||||
|
{activeDownload.status === "downloading"
|
||||||
|
? activeDownload.progress
|
||||||
|
? `${Math.round(activeDownload.progress.percent * 100)}% · ${formatSpeed(activeDownload.progress.speed)} · ${formatEta(activeDownload.progress.eta)}`
|
||||||
|
: "Starting..."
|
||||||
|
: activeDownload.status === "complete"
|
||||||
|
? "Download complete"
|
||||||
|
: activeDownload.message ?? "Download failed"}
|
||||||
|
</Text>
|
||||||
|
{activeDownload.status === "downloading" && activeDownload.progress && (
|
||||||
|
<View style={styles.progressBar}>
|
||||||
|
<View
|
||||||
|
style={[
|
||||||
|
styles.progressFill,
|
||||||
|
{ width: `${Math.round(activeDownload.progress.percent * 100)}%` },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
{activeDownload.status !== "downloading" && (
|
||||||
|
<Pressable
|
||||||
|
onPress={() => dismissDownload(activeDownload.id)}
|
||||||
|
hitSlop={8}
|
||||||
|
style={styles.dismiss}
|
||||||
|
>
|
||||||
|
<X size={16} color={theme.colors.foregroundMuted} />
|
||||||
|
</Pressable>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create((theme) => ({
|
||||||
|
container: {
|
||||||
|
position: "absolute",
|
||||||
|
bottom: theme.spacing[4],
|
||||||
|
left: theme.spacing[4],
|
||||||
|
right: theme.spacing[4],
|
||||||
|
zIndex: 1000,
|
||||||
|
},
|
||||||
|
toast: {
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: theme.spacing[3],
|
||||||
|
backgroundColor: theme.colors.surface2,
|
||||||
|
borderRadius: theme.borderRadius.lg,
|
||||||
|
borderWidth: theme.borderWidth[1],
|
||||||
|
borderColor: theme.colors.border,
|
||||||
|
paddingVertical: theme.spacing[3],
|
||||||
|
paddingHorizontal: theme.spacing[4],
|
||||||
|
shadowColor: "#000",
|
||||||
|
shadowOffset: { width: 0, height: 4 },
|
||||||
|
shadowOpacity: 0.15,
|
||||||
|
shadowRadius: 8,
|
||||||
|
elevation: 8,
|
||||||
|
},
|
||||||
|
textContainer: {
|
||||||
|
flex: 1,
|
||||||
|
gap: theme.spacing[1],
|
||||||
|
},
|
||||||
|
fileName: {
|
||||||
|
color: theme.colors.foreground,
|
||||||
|
fontSize: theme.fontSize.sm,
|
||||||
|
fontWeight: theme.fontWeight.semibold,
|
||||||
|
},
|
||||||
|
status: {
|
||||||
|
color: theme.colors.foregroundMuted,
|
||||||
|
fontSize: theme.fontSize.xs,
|
||||||
|
},
|
||||||
|
progressBar: {
|
||||||
|
height: 3,
|
||||||
|
backgroundColor: theme.colors.surface2,
|
||||||
|
borderRadius: theme.borderRadius.full,
|
||||||
|
marginTop: theme.spacing[1],
|
||||||
|
overflow: "hidden",
|
||||||
|
},
|
||||||
|
progressFill: {
|
||||||
|
height: "100%",
|
||||||
|
backgroundColor: theme.colors.primary,
|
||||||
|
borderRadius: theme.borderRadius.full,
|
||||||
|
},
|
||||||
|
dismiss: {
|
||||||
|
padding: theme.spacing[1],
|
||||||
|
},
|
||||||
|
}));
|
||||||
@@ -10,7 +10,6 @@ import {
|
|||||||
NativeScrollEvent,
|
NativeScrollEvent,
|
||||||
NativeSyntheticEvent,
|
NativeSyntheticEvent,
|
||||||
Modal,
|
Modal,
|
||||||
Platform,
|
|
||||||
Pressable,
|
Pressable,
|
||||||
ScrollView,
|
ScrollView,
|
||||||
Text,
|
Text,
|
||||||
@@ -20,9 +19,6 @@ import {
|
|||||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||||
import { Fonts } from "@/constants/theme";
|
import { Fonts } from "@/constants/theme";
|
||||||
import * as Clipboard from "expo-clipboard";
|
import * as Clipboard from "expo-clipboard";
|
||||||
import { File as FSFile, Paths } from "expo-file-system";
|
|
||||||
import * as LegacyFileSystem from "expo-file-system/legacy";
|
|
||||||
import * as Sharing from "expo-sharing";
|
|
||||||
import {
|
import {
|
||||||
BottomSheetModal,
|
BottomSheetModal,
|
||||||
BottomSheetScrollView,
|
BottomSheetScrollView,
|
||||||
@@ -30,7 +26,6 @@ import {
|
|||||||
BottomSheetView,
|
BottomSheetView,
|
||||||
} from "@gorhom/bottom-sheet";
|
} from "@gorhom/bottom-sheet";
|
||||||
import {
|
import {
|
||||||
Check,
|
|
||||||
ChevronDown,
|
ChevronDown,
|
||||||
Download,
|
Download,
|
||||||
File,
|
File,
|
||||||
@@ -39,12 +34,11 @@ import {
|
|||||||
Image as ImageIcon,
|
Image as ImageIcon,
|
||||||
MoreVertical,
|
MoreVertical,
|
||||||
X,
|
X,
|
||||||
XCircle,
|
|
||||||
} from "lucide-react-native";
|
} from "lucide-react-native";
|
||||||
import type { ExplorerEntry } from "@/stores/session-store";
|
import type { ExplorerEntry } from "@/stores/session-store";
|
||||||
import { useDaemonConnections } from "@/contexts/daemon-connections-context";
|
import { useDaemonConnections } from "@/contexts/daemon-connections-context";
|
||||||
import type { DaemonProfile } from "@/contexts/daemon-registry-context";
|
|
||||||
import { useSessionStore } from "@/stores/session-store";
|
import { useSessionStore } from "@/stores/session-store";
|
||||||
|
import { useDownloadStore } from "@/stores/download-store";
|
||||||
import {
|
import {
|
||||||
usePanelStore,
|
usePanelStore,
|
||||||
type SortOption,
|
type SortOption,
|
||||||
@@ -170,21 +164,7 @@ export function FileExplorerPane({
|
|||||||
const [menuAnchor, setMenuAnchor] = useState({ top: 0, left: 0 });
|
const [menuAnchor, setMenuAnchor] = useState({ top: 0, left: 0 });
|
||||||
const [menuHeight, setMenuHeight] = useState(0);
|
const [menuHeight, setMenuHeight] = useState(0);
|
||||||
const [isRefreshing, setIsRefreshing] = useState(false);
|
const [isRefreshing, setIsRefreshing] = useState(false);
|
||||||
const [downloadToast, setDownloadToast] = useState<{
|
const startDownload = useDownloadStore((state) => state.startDownload);
|
||||||
status: "downloading" | "complete" | "error";
|
|
||||||
fileName: string;
|
|
||||||
message?: string;
|
|
||||||
progress?: {
|
|
||||||
percent: number;
|
|
||||||
bytesWritten: number;
|
|
||||||
totalBytes: number;
|
|
||||||
speed: number;
|
|
||||||
eta: number;
|
|
||||||
};
|
|
||||||
} | null>(null);
|
|
||||||
const downloadStartTimeRef = useRef<number>(0);
|
|
||||||
const lastProgressRef = useRef<{ bytes: number; time: number } | null>(null);
|
|
||||||
const downloadToastTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
||||||
const agentIdRef = useRef(agentId);
|
const agentIdRef = useRef(agentId);
|
||||||
const viewModeRef = useRef(viewMode);
|
const viewModeRef = useRef(viewMode);
|
||||||
const requestFilePreviewRef = useRef(requestFilePreview);
|
const requestFilePreviewRef = useRef(requestFilePreview);
|
||||||
@@ -434,121 +414,22 @@ export function FileExplorerPane({
|
|||||||
setMenuHeight((current) => (current === height ? current : height));
|
setMenuHeight((current) => (current === height ? current : height));
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const showDownloadToast = useCallback(
|
|
||||||
(toast: { status: "downloading" | "complete" | "error"; fileName: string; message?: string }) => {
|
|
||||||
if (downloadToastTimeoutRef.current) {
|
|
||||||
clearTimeout(downloadToastTimeoutRef.current);
|
|
||||||
downloadToastTimeoutRef.current = null;
|
|
||||||
}
|
|
||||||
setDownloadToast(toast);
|
|
||||||
if (toast.status !== "downloading") {
|
|
||||||
downloadToastTimeoutRef.current = setTimeout(() => {
|
|
||||||
setDownloadToast(null);
|
|
||||||
}, 3000);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[]
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleDownloadEntry = useCallback(
|
const handleDownloadEntry = useCallback(
|
||||||
async (entry: ExplorerEntry) => {
|
(entry: ExplorerEntry) => {
|
||||||
if (!agentId || !requestFileDownloadToken || entry.kind !== "file") {
|
if (!agentId || !requestFileDownloadToken || entry.kind !== "file") {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const displayName = entry.name;
|
startDownload({
|
||||||
|
serverId,
|
||||||
try {
|
agentId,
|
||||||
const tokenResponse = await requestFileDownloadToken(agentId, entry.path);
|
fileName: entry.name,
|
||||||
if (tokenResponse.error || !tokenResponse.token) {
|
path: entry.path,
|
||||||
throw new Error(tokenResponse.error ?? "Failed to request download token.");
|
daemonProfile,
|
||||||
}
|
requestFileDownloadToken,
|
||||||
|
});
|
||||||
const downloadTarget = resolveDaemonDownloadTarget(daemonProfile);
|
|
||||||
if (!downloadTarget.baseUrl) {
|
|
||||||
throw new Error("Download host is unavailable.");
|
|
||||||
}
|
|
||||||
|
|
||||||
const fileName = tokenResponse.fileName ?? entry.name;
|
|
||||||
const downloadUrl = buildDownloadUrl(
|
|
||||||
downloadTarget.baseUrl,
|
|
||||||
tokenResponse.token,
|
|
||||||
Platform.OS === "web" ? downloadTarget.authCredentials : null
|
|
||||||
);
|
|
||||||
|
|
||||||
if (Platform.OS === "web") {
|
|
||||||
triggerBrowserDownload(downloadUrl, fileName);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
downloadStartTimeRef.current = Date.now();
|
|
||||||
lastProgressRef.current = null;
|
|
||||||
showDownloadToast({ status: "downloading", fileName: displayName });
|
|
||||||
|
|
||||||
const targetFile = resolveDownloadTargetFile(fileName);
|
|
||||||
const downloadResumable = LegacyFileSystem.createDownloadResumable(
|
|
||||||
downloadUrl,
|
|
||||||
targetFile.uri,
|
|
||||||
downloadTarget.authHeader
|
|
||||||
? { headers: { Authorization: downloadTarget.authHeader } }
|
|
||||||
: undefined,
|
|
||||||
(data) => {
|
|
||||||
const now = Date.now();
|
|
||||||
const { totalBytesWritten, totalBytesExpectedToWrite } = data;
|
|
||||||
|
|
||||||
if (totalBytesExpectedToWrite <= 0) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const percent = totalBytesWritten / totalBytesExpectedToWrite;
|
|
||||||
const elapsed = (now - downloadStartTimeRef.current) / 1000;
|
|
||||||
const speed = elapsed > 0 ? totalBytesWritten / elapsed : 0;
|
|
||||||
const remaining = totalBytesExpectedToWrite - totalBytesWritten;
|
|
||||||
const eta = speed > 0 ? remaining / speed : 0;
|
|
||||||
|
|
||||||
lastProgressRef.current = { bytes: totalBytesWritten, time: now };
|
|
||||||
|
|
||||||
setDownloadToast((prev) =>
|
|
||||||
prev?.status === "downloading"
|
|
||||||
? {
|
|
||||||
...prev,
|
|
||||||
progress: {
|
|
||||||
percent,
|
|
||||||
bytesWritten: totalBytesWritten,
|
|
||||||
totalBytes: totalBytesExpectedToWrite,
|
|
||||||
speed,
|
|
||||||
eta,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
: prev
|
|
||||||
);
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
const result = await downloadResumable.downloadAsync();
|
|
||||||
if (!result) {
|
|
||||||
throw new Error("Download was cancelled.");
|
|
||||||
}
|
|
||||||
|
|
||||||
showDownloadToast({ status: "complete", fileName: displayName });
|
|
||||||
|
|
||||||
if (await Sharing.isAvailableAsync()) {
|
|
||||||
await Sharing.shareAsync(result.uri, {
|
|
||||||
mimeType: tokenResponse.mimeType ?? undefined,
|
|
||||||
dialogTitle: fileName ? `Share ${fileName}` : "Share file",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
const message =
|
|
||||||
error instanceof Error ? error.message : "Failed to download file.";
|
|
||||||
if (Platform.OS === "web") {
|
|
||||||
console.warn("[FileExplorer] Download failed:", message);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
showDownloadToast({ status: "error", fileName: displayName, message });
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
[agentId, daemonProfile, requestFileDownloadToken, showDownloadToast]
|
[agentId, serverId, daemonProfile, requestFileDownloadToken, startDownload]
|
||||||
);
|
);
|
||||||
|
|
||||||
const menuPosition = useMemo(() => {
|
const menuPosition = useMemo(() => {
|
||||||
@@ -991,53 +872,6 @@ export function FileExplorerPane({
|
|||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
</BottomSheetModal>
|
</BottomSheetModal>
|
||||||
|
|
||||||
{downloadToast && (
|
|
||||||
<View style={styles.downloadToast}>
|
|
||||||
<View style={styles.downloadToastContent}>
|
|
||||||
{downloadToast.status === "downloading" ? (
|
|
||||||
<ActivityIndicator size="small" color={theme.colors.foreground} />
|
|
||||||
) : downloadToast.status === "complete" ? (
|
|
||||||
<Check size={18} color={theme.colors.primary} />
|
|
||||||
) : (
|
|
||||||
<XCircle size={18} color={theme.colors.destructive} />
|
|
||||||
)}
|
|
||||||
<View style={styles.downloadToastTextContainer}>
|
|
||||||
<Text style={styles.downloadToastFileName} numberOfLines={1}>
|
|
||||||
{downloadToast.fileName}
|
|
||||||
</Text>
|
|
||||||
<Text style={styles.downloadToastStatus}>
|
|
||||||
{downloadToast.status === "downloading"
|
|
||||||
? downloadToast.progress
|
|
||||||
? `${Math.round(downloadToast.progress.percent * 100)}% · ${formatSpeed(downloadToast.progress.speed)} · ${formatEta(downloadToast.progress.eta)}`
|
|
||||||
: "Starting..."
|
|
||||||
: downloadToast.status === "complete"
|
|
||||||
? "Download complete"
|
|
||||||
: downloadToast.message ?? "Download failed"}
|
|
||||||
</Text>
|
|
||||||
{downloadToast.status === "downloading" && downloadToast.progress && (
|
|
||||||
<View style={styles.downloadProgressBar}>
|
|
||||||
<View
|
|
||||||
style={[
|
|
||||||
styles.downloadProgressFill,
|
|
||||||
{ width: `${Math.round(downloadToast.progress.percent * 100)}%` },
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
)}
|
|
||||||
</View>
|
|
||||||
{downloadToast.status !== "downloading" && (
|
|
||||||
<Pressable
|
|
||||||
onPress={() => setDownloadToast(null)}
|
|
||||||
hitSlop={8}
|
|
||||||
style={styles.downloadToastDismiss}
|
|
||||||
>
|
|
||||||
<X size={16} color={theme.colors.foregroundMuted} />
|
|
||||||
</Pressable>
|
|
||||||
)}
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
)}
|
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1056,28 +890,6 @@ function formatFileSize({ size }: { size: number }): string {
|
|||||||
return `${(size / (1024 * 1024)).toFixed(1)} MB`;
|
return `${(size / (1024 * 1024)).toFixed(1)} MB`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatSpeed(bytesPerSecond: number): string {
|
|
||||||
if (bytesPerSecond < 1024) {
|
|
||||||
return `${Math.round(bytesPerSecond)} B/s`;
|
|
||||||
}
|
|
||||||
if (bytesPerSecond < 1024 * 1024) {
|
|
||||||
return `${(bytesPerSecond / 1024).toFixed(1)} KB/s`;
|
|
||||||
}
|
|
||||||
return `${(bytesPerSecond / (1024 * 1024)).toFixed(1)} MB/s`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatEta(seconds: number): string {
|
|
||||||
if (seconds < 1) {
|
|
||||||
return "< 1s";
|
|
||||||
}
|
|
||||||
if (seconds < 60) {
|
|
||||||
return `${Math.round(seconds)}s`;
|
|
||||||
}
|
|
||||||
const mins = Math.floor(seconds / 60);
|
|
||||||
const secs = Math.round(seconds % 60);
|
|
||||||
return `${mins}m ${secs}s`;
|
|
||||||
}
|
|
||||||
|
|
||||||
type EntryDisplayKind = "directory" | "image" | "text" | "other";
|
type EntryDisplayKind = "directory" | "image" | "text" | "other";
|
||||||
|
|
||||||
const IMAGE_EXTENSIONS = new Set([
|
const IMAGE_EXTENSIONS = new Set([
|
||||||
@@ -1176,120 +988,6 @@ function getExtension(name: string): string | null {
|
|||||||
return name.slice(index + 1).toLowerCase();
|
return name.slice(index + 1).toLowerCase();
|
||||||
}
|
}
|
||||||
|
|
||||||
type DownloadTarget = {
|
|
||||||
baseUrl: string | null;
|
|
||||||
authHeader: string | null;
|
|
||||||
authCredentials: { username: string; password: string } | null;
|
|
||||||
};
|
|
||||||
|
|
||||||
function resolveDaemonDownloadTarget(daemon?: DaemonProfile): DownloadTarget {
|
|
||||||
const rawUrl = daemon?.restUrl ?? daemon?.wsUrl;
|
|
||||||
if (!rawUrl) {
|
|
||||||
return { baseUrl: null, authHeader: null, authCredentials: null };
|
|
||||||
}
|
|
||||||
|
|
||||||
let parsed: URL;
|
|
||||||
try {
|
|
||||||
parsed = new URL(rawUrl);
|
|
||||||
} catch {
|
|
||||||
return { baseUrl: null, authHeader: null, authCredentials: null };
|
|
||||||
}
|
|
||||||
|
|
||||||
if (parsed.protocol === "ws:") {
|
|
||||||
parsed.protocol = "http:";
|
|
||||||
} else if (parsed.protocol === "wss:") {
|
|
||||||
parsed.protocol = "https:";
|
|
||||||
}
|
|
||||||
|
|
||||||
let authCredentials: { username: string; password: string } | null = null;
|
|
||||||
if (parsed.username || parsed.password) {
|
|
||||||
authCredentials = {
|
|
||||||
username: decodeURIComponent(parsed.username),
|
|
||||||
password: decodeURIComponent(parsed.password),
|
|
||||||
};
|
|
||||||
parsed.username = "";
|
|
||||||
parsed.password = "";
|
|
||||||
}
|
|
||||||
|
|
||||||
parsed.pathname = parsed.pathname.replace(/\/ws\/?$/, "/");
|
|
||||||
|
|
||||||
const baseUrl = parsed.origin;
|
|
||||||
const authHeader = authCredentials
|
|
||||||
? `Basic ${btoa(`${authCredentials.username}:${authCredentials.password}`)}`
|
|
||||||
: null;
|
|
||||||
|
|
||||||
return { baseUrl, authHeader, authCredentials };
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildDownloadUrl(
|
|
||||||
baseUrl: string,
|
|
||||||
token: string,
|
|
||||||
authCredentials: { username: string; password: string } | null
|
|
||||||
): string {
|
|
||||||
const url = new URL("/api/files/download", baseUrl);
|
|
||||||
url.searchParams.set("token", token);
|
|
||||||
if (authCredentials) {
|
|
||||||
url.username = authCredentials.username;
|
|
||||||
url.password = authCredentials.password;
|
|
||||||
}
|
|
||||||
return url.toString();
|
|
||||||
}
|
|
||||||
|
|
||||||
function triggerBrowserDownload(url: string, fileName: string) {
|
|
||||||
if (typeof document === "undefined") {
|
|
||||||
if (typeof window !== "undefined") {
|
|
||||||
window.open(url, "_blank", "noopener");
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const link = document.createElement("a");
|
|
||||||
link.href = url;
|
|
||||||
link.download = fileName;
|
|
||||||
link.rel = "noopener";
|
|
||||||
document.body.appendChild(link);
|
|
||||||
link.click();
|
|
||||||
link.remove();
|
|
||||||
}
|
|
||||||
|
|
||||||
function resolveDownloadTargetFile(fileName: string): FSFile {
|
|
||||||
const directory = Paths.cache ?? Paths.document;
|
|
||||||
if (!directory) {
|
|
||||||
throw new Error("No download directory available.");
|
|
||||||
}
|
|
||||||
|
|
||||||
const safeName = sanitizeDownloadFileName(fileName);
|
|
||||||
const split = splitFileName(safeName);
|
|
||||||
let targetFile = new FSFile(directory, safeName);
|
|
||||||
let suffix = 1;
|
|
||||||
|
|
||||||
while (targetFile.exists) {
|
|
||||||
targetFile = new FSFile(directory, `${split.base} (${suffix})${split.ext}`);
|
|
||||||
suffix += 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
return targetFile;
|
|
||||||
}
|
|
||||||
|
|
||||||
function sanitizeDownloadFileName(fileName: string): string {
|
|
||||||
const trimmed = fileName.trim();
|
|
||||||
if (!trimmed) {
|
|
||||||
return "download";
|
|
||||||
}
|
|
||||||
return trimmed.replace(/[\\/:*?"<>|]+/g, "_");
|
|
||||||
}
|
|
||||||
|
|
||||||
function splitFileName(fileName: string): { base: string; ext: string } {
|
|
||||||
const lastDot = fileName.lastIndexOf(".");
|
|
||||||
if (lastDot <= 0) {
|
|
||||||
return { base: fileName, ext: "" };
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
base: fileName.slice(0, lastDot),
|
|
||||||
ext: fileName.slice(lastDot),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const styles = StyleSheet.create((theme) => ({
|
const styles = StyleSheet.create((theme) => ({
|
||||||
container: {
|
container: {
|
||||||
flex: 1,
|
flex: 1,
|
||||||
@@ -1577,55 +1275,4 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
width: "100%",
|
width: "100%",
|
||||||
aspectRatio: 1,
|
aspectRatio: 1,
|
||||||
},
|
},
|
||||||
downloadToast: {
|
|
||||||
position: "absolute",
|
|
||||||
bottom: theme.spacing[4],
|
|
||||||
left: theme.spacing[4],
|
|
||||||
right: theme.spacing[4],
|
|
||||||
zIndex: 1000,
|
|
||||||
},
|
|
||||||
downloadToastContent: {
|
|
||||||
flexDirection: "row",
|
|
||||||
alignItems: "center",
|
|
||||||
gap: theme.spacing[3],
|
|
||||||
backgroundColor: theme.colors.surface2,
|
|
||||||
borderRadius: theme.borderRadius.lg,
|
|
||||||
borderWidth: theme.borderWidth[1],
|
|
||||||
borderColor: theme.colors.border,
|
|
||||||
paddingVertical: theme.spacing[3],
|
|
||||||
paddingHorizontal: theme.spacing[4],
|
|
||||||
shadowColor: "#000",
|
|
||||||
shadowOffset: { width: 0, height: 4 },
|
|
||||||
shadowOpacity: 0.15,
|
|
||||||
shadowRadius: 8,
|
|
||||||
elevation: 8,
|
|
||||||
},
|
|
||||||
downloadToastTextContainer: {
|
|
||||||
flex: 1,
|
|
||||||
gap: theme.spacing[1],
|
|
||||||
},
|
|
||||||
downloadToastFileName: {
|
|
||||||
color: theme.colors.foreground,
|
|
||||||
fontSize: theme.fontSize.sm,
|
|
||||||
fontWeight: theme.fontWeight.semibold,
|
|
||||||
},
|
|
||||||
downloadToastStatus: {
|
|
||||||
color: theme.colors.foregroundMuted,
|
|
||||||
fontSize: theme.fontSize.xs,
|
|
||||||
},
|
|
||||||
downloadProgressBar: {
|
|
||||||
height: 3,
|
|
||||||
backgroundColor: theme.colors.surface2,
|
|
||||||
borderRadius: theme.borderRadius.full,
|
|
||||||
marginTop: theme.spacing[1],
|
|
||||||
overflow: "hidden",
|
|
||||||
},
|
|
||||||
downloadProgressFill: {
|
|
||||||
height: "100%",
|
|
||||||
backgroundColor: theme.colors.primary,
|
|
||||||
borderRadius: theme.borderRadius.full,
|
|
||||||
},
|
|
||||||
downloadToastDismiss: {
|
|
||||||
padding: theme.spacing[1],
|
|
||||||
},
|
|
||||||
}));
|
}));
|
||||||
|
|||||||
@@ -286,6 +286,11 @@ const DiffFileSection = memo(function DiffFileSection({
|
|||||||
<Text style={styles.newBadgeText}>New</Text>
|
<Text style={styles.newBadgeText}>New</Text>
|
||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
|
{file.isDeleted && (
|
||||||
|
<View style={styles.deletedBadge}>
|
||||||
|
<Text style={styles.deletedBadgeText}>Deleted</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
</View>
|
</View>
|
||||||
<View style={styles.fileHeaderRight}>
|
<View style={styles.fileHeaderRight}>
|
||||||
<Text style={styles.additions}>+{file.additions}</Text>
|
<Text style={styles.additions}>+{file.additions}</Text>
|
||||||
@@ -579,6 +584,18 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
fontWeight: theme.fontWeight.normal,
|
fontWeight: theme.fontWeight.normal,
|
||||||
color: theme.colors.palette.green[400],
|
color: theme.colors.palette.green[400],
|
||||||
},
|
},
|
||||||
|
deletedBadge: {
|
||||||
|
backgroundColor: "rgba(248, 81, 73, 0.2)",
|
||||||
|
paddingHorizontal: theme.spacing[2],
|
||||||
|
paddingVertical: theme.spacing[1],
|
||||||
|
borderRadius: theme.borderRadius.md,
|
||||||
|
flexShrink: 0,
|
||||||
|
},
|
||||||
|
deletedBadgeText: {
|
||||||
|
fontSize: theme.fontSize.xs,
|
||||||
|
fontWeight: theme.fontWeight.normal,
|
||||||
|
color: theme.colors.palette.red[500],
|
||||||
|
},
|
||||||
additions: {
|
additions: {
|
||||||
fontSize: theme.fontSize.sm,
|
fontSize: theme.fontSize.sm,
|
||||||
fontWeight: theme.fontWeight.normal,
|
fontWeight: theme.fontWeight.normal,
|
||||||
|
|||||||
376
packages/app/src/stores/download-store.ts
Normal file
@@ -0,0 +1,376 @@
|
|||||||
|
import { create } from "zustand";
|
||||||
|
import { Platform } from "react-native";
|
||||||
|
import { File as FSFile, Paths } from "expo-file-system";
|
||||||
|
import * as LegacyFileSystem from "expo-file-system/legacy";
|
||||||
|
import * as Sharing from "expo-sharing";
|
||||||
|
import type { DaemonProfile } from "@/contexts/daemon-registry-context";
|
||||||
|
|
||||||
|
interface DownloadProgress {
|
||||||
|
percent: number;
|
||||||
|
bytesWritten: number;
|
||||||
|
totalBytes: number;
|
||||||
|
speed: number;
|
||||||
|
eta: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Download {
|
||||||
|
id: string;
|
||||||
|
serverId: string;
|
||||||
|
agentId: string;
|
||||||
|
fileName: string;
|
||||||
|
status: "downloading" | "complete" | "error";
|
||||||
|
message?: string;
|
||||||
|
progress?: DownloadProgress;
|
||||||
|
startedAt: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DownloadState {
|
||||||
|
downloads: Map<string, Download>;
|
||||||
|
activeDownloadId: string | null;
|
||||||
|
|
||||||
|
startDownload: (params: {
|
||||||
|
serverId: string;
|
||||||
|
agentId: string;
|
||||||
|
fileName: string;
|
||||||
|
path: string;
|
||||||
|
daemonProfile: DaemonProfile | undefined;
|
||||||
|
requestFileDownloadToken: (
|
||||||
|
agentId: string,
|
||||||
|
path: string
|
||||||
|
) => Promise<{
|
||||||
|
token: string | null;
|
||||||
|
fileName: string | null;
|
||||||
|
mimeType: string | null;
|
||||||
|
error: string | null;
|
||||||
|
}>;
|
||||||
|
}) => Promise<void>;
|
||||||
|
|
||||||
|
updateProgress: (id: string, progress: DownloadProgress) => void;
|
||||||
|
completeDownload: (id: string) => void;
|
||||||
|
failDownload: (id: string, message: string) => void;
|
||||||
|
dismissDownload: (id: string) => void;
|
||||||
|
dismissAllCompleted: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function generateDownloadId(): string {
|
||||||
|
return `${Date.now()}-${Math.random().toString(36).slice(2, 9)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useDownloadStore = create<DownloadState>()((set, get) => ({
|
||||||
|
downloads: new Map(),
|
||||||
|
activeDownloadId: null,
|
||||||
|
|
||||||
|
startDownload: async ({
|
||||||
|
serverId,
|
||||||
|
agentId,
|
||||||
|
fileName,
|
||||||
|
path,
|
||||||
|
daemonProfile,
|
||||||
|
requestFileDownloadToken,
|
||||||
|
}) => {
|
||||||
|
const id = generateDownloadId();
|
||||||
|
const download: Download = {
|
||||||
|
id,
|
||||||
|
serverId,
|
||||||
|
agentId,
|
||||||
|
fileName,
|
||||||
|
status: "downloading",
|
||||||
|
startedAt: Date.now(),
|
||||||
|
};
|
||||||
|
|
||||||
|
set((state) => ({
|
||||||
|
downloads: new Map(state.downloads).set(id, download),
|
||||||
|
activeDownloadId: id,
|
||||||
|
}));
|
||||||
|
|
||||||
|
try {
|
||||||
|
const tokenResponse = await requestFileDownloadToken(agentId, path);
|
||||||
|
if (tokenResponse.error || !tokenResponse.token) {
|
||||||
|
throw new Error(tokenResponse.error ?? "Failed to request download token.");
|
||||||
|
}
|
||||||
|
|
||||||
|
const downloadTarget = resolveDaemonDownloadTarget(daemonProfile);
|
||||||
|
if (!downloadTarget.baseUrl) {
|
||||||
|
throw new Error("Download host is unavailable.");
|
||||||
|
}
|
||||||
|
|
||||||
|
const resolvedFileName = tokenResponse.fileName ?? fileName;
|
||||||
|
const downloadUrl = buildDownloadUrl(
|
||||||
|
downloadTarget.baseUrl,
|
||||||
|
tokenResponse.token,
|
||||||
|
Platform.OS === "web" ? downloadTarget.authCredentials : null
|
||||||
|
);
|
||||||
|
|
||||||
|
if (Platform.OS === "web") {
|
||||||
|
triggerBrowserDownload(downloadUrl, resolvedFileName);
|
||||||
|
get().completeDownload(id);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const downloadStartTime = Date.now();
|
||||||
|
const targetFile = resolveDownloadTargetFile(resolvedFileName);
|
||||||
|
const downloadResumable = LegacyFileSystem.createDownloadResumable(
|
||||||
|
downloadUrl,
|
||||||
|
targetFile.uri,
|
||||||
|
downloadTarget.authHeader
|
||||||
|
? { headers: { Authorization: downloadTarget.authHeader } }
|
||||||
|
: undefined,
|
||||||
|
(data) => {
|
||||||
|
const now = Date.now();
|
||||||
|
const { totalBytesWritten, totalBytesExpectedToWrite } = data;
|
||||||
|
|
||||||
|
if (totalBytesExpectedToWrite <= 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const percent = totalBytesWritten / totalBytesExpectedToWrite;
|
||||||
|
const elapsed = (now - downloadStartTime) / 1000;
|
||||||
|
const speed = elapsed > 0 ? totalBytesWritten / elapsed : 0;
|
||||||
|
const remaining = totalBytesExpectedToWrite - totalBytesWritten;
|
||||||
|
const eta = speed > 0 ? remaining / speed : 0;
|
||||||
|
|
||||||
|
get().updateProgress(id, {
|
||||||
|
percent,
|
||||||
|
bytesWritten: totalBytesWritten,
|
||||||
|
totalBytes: totalBytesExpectedToWrite,
|
||||||
|
speed,
|
||||||
|
eta,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = await downloadResumable.downloadAsync();
|
||||||
|
if (!result) {
|
||||||
|
throw new Error("Download was cancelled.");
|
||||||
|
}
|
||||||
|
|
||||||
|
get().completeDownload(id);
|
||||||
|
|
||||||
|
if (await Sharing.isAvailableAsync()) {
|
||||||
|
await Sharing.shareAsync(result.uri, {
|
||||||
|
mimeType: tokenResponse.mimeType ?? undefined,
|
||||||
|
dialogTitle: resolvedFileName ? `Share ${resolvedFileName}` : "Share file",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : "Failed to download file.";
|
||||||
|
if (Platform.OS === "web") {
|
||||||
|
console.warn("[DownloadStore] Download failed:", message);
|
||||||
|
get().failDownload(id, message);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
get().failDownload(id, message);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
updateProgress: (id, progress) => {
|
||||||
|
set((state) => {
|
||||||
|
const download = state.downloads.get(id);
|
||||||
|
if (!download || download.status !== "downloading") {
|
||||||
|
return state;
|
||||||
|
}
|
||||||
|
const updated = new Map(state.downloads);
|
||||||
|
updated.set(id, { ...download, progress });
|
||||||
|
return { downloads: updated };
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
completeDownload: (id) => {
|
||||||
|
set((state) => {
|
||||||
|
const download = state.downloads.get(id);
|
||||||
|
if (!download) {
|
||||||
|
return state;
|
||||||
|
}
|
||||||
|
const updated = new Map(state.downloads);
|
||||||
|
updated.set(id, { ...download, status: "complete" });
|
||||||
|
return { downloads: updated };
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
failDownload: (id, message) => {
|
||||||
|
set((state) => {
|
||||||
|
const download = state.downloads.get(id);
|
||||||
|
if (!download) {
|
||||||
|
return state;
|
||||||
|
}
|
||||||
|
const updated = new Map(state.downloads);
|
||||||
|
updated.set(id, { ...download, status: "error", message });
|
||||||
|
return { downloads: updated };
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
dismissDownload: (id) => {
|
||||||
|
set((state) => {
|
||||||
|
const updated = new Map(state.downloads);
|
||||||
|
updated.delete(id);
|
||||||
|
const newActiveId =
|
||||||
|
state.activeDownloadId === id
|
||||||
|
? findMostRecentDownloadId(updated)
|
||||||
|
: state.activeDownloadId;
|
||||||
|
return { downloads: updated, activeDownloadId: newActiveId };
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
dismissAllCompleted: () => {
|
||||||
|
set((state) => {
|
||||||
|
const updated = new Map(state.downloads);
|
||||||
|
for (const [id, download] of updated) {
|
||||||
|
if (download.status !== "downloading") {
|
||||||
|
updated.delete(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const newActiveId = state.activeDownloadId
|
||||||
|
? updated.has(state.activeDownloadId)
|
||||||
|
? state.activeDownloadId
|
||||||
|
: findMostRecentDownloadId(updated)
|
||||||
|
: null;
|
||||||
|
return { downloads: updated, activeDownloadId: newActiveId };
|
||||||
|
});
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
function findMostRecentDownloadId(downloads: Map<string, Download>): string | null {
|
||||||
|
let mostRecent: Download | null = null;
|
||||||
|
for (const download of downloads.values()) {
|
||||||
|
if (!mostRecent || download.startedAt > mostRecent.startedAt) {
|
||||||
|
mostRecent = download;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return mostRecent?.id ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
type DownloadTarget = {
|
||||||
|
baseUrl: string | null;
|
||||||
|
authHeader: string | null;
|
||||||
|
authCredentials: { username: string; password: string } | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
function resolveDaemonDownloadTarget(daemon?: DaemonProfile): DownloadTarget {
|
||||||
|
const rawUrl = daemon?.restUrl ?? daemon?.wsUrl;
|
||||||
|
if (!rawUrl) {
|
||||||
|
return { baseUrl: null, authHeader: null, authCredentials: null };
|
||||||
|
}
|
||||||
|
|
||||||
|
let parsed: URL;
|
||||||
|
try {
|
||||||
|
parsed = new URL(rawUrl);
|
||||||
|
} catch {
|
||||||
|
return { baseUrl: null, authHeader: null, authCredentials: null };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (parsed.protocol === "ws:") {
|
||||||
|
parsed.protocol = "http:";
|
||||||
|
} else if (parsed.protocol === "wss:") {
|
||||||
|
parsed.protocol = "https:";
|
||||||
|
}
|
||||||
|
|
||||||
|
let authCredentials: { username: string; password: string } | null = null;
|
||||||
|
if (parsed.username || parsed.password) {
|
||||||
|
authCredentials = {
|
||||||
|
username: decodeURIComponent(parsed.username),
|
||||||
|
password: decodeURIComponent(parsed.password),
|
||||||
|
};
|
||||||
|
parsed.username = "";
|
||||||
|
parsed.password = "";
|
||||||
|
}
|
||||||
|
|
||||||
|
parsed.pathname = parsed.pathname.replace(/\/ws\/?$/, "/");
|
||||||
|
|
||||||
|
const baseUrl = parsed.origin;
|
||||||
|
const authHeader = authCredentials
|
||||||
|
? `Basic ${btoa(`${authCredentials.username}:${authCredentials.password}`)}`
|
||||||
|
: null;
|
||||||
|
|
||||||
|
return { baseUrl, authHeader, authCredentials };
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildDownloadUrl(
|
||||||
|
baseUrl: string,
|
||||||
|
token: string,
|
||||||
|
authCredentials: { username: string; password: string } | null
|
||||||
|
): string {
|
||||||
|
const url = new URL("/api/files/download", baseUrl);
|
||||||
|
url.searchParams.set("token", token);
|
||||||
|
if (authCredentials) {
|
||||||
|
url.username = authCredentials.username;
|
||||||
|
url.password = authCredentials.password;
|
||||||
|
}
|
||||||
|
return url.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
function triggerBrowserDownload(url: string, fileName: string) {
|
||||||
|
if (typeof document === "undefined") {
|
||||||
|
if (typeof window !== "undefined") {
|
||||||
|
window.open(url, "_blank", "noopener");
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const link = document.createElement("a");
|
||||||
|
link.href = url;
|
||||||
|
link.download = fileName;
|
||||||
|
link.rel = "noopener";
|
||||||
|
document.body.appendChild(link);
|
||||||
|
link.click();
|
||||||
|
link.remove();
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveDownloadTargetFile(fileName: string): FSFile {
|
||||||
|
const directory = Paths.cache ?? Paths.document;
|
||||||
|
if (!directory) {
|
||||||
|
throw new Error("No download directory available.");
|
||||||
|
}
|
||||||
|
|
||||||
|
const safeName = sanitizeDownloadFileName(fileName);
|
||||||
|
const split = splitFileName(safeName);
|
||||||
|
let targetFile = new FSFile(directory, safeName);
|
||||||
|
let suffix = 1;
|
||||||
|
|
||||||
|
while (targetFile.exists) {
|
||||||
|
targetFile = new FSFile(directory, `${split.base} (${suffix})${split.ext}`);
|
||||||
|
suffix += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
return targetFile;
|
||||||
|
}
|
||||||
|
|
||||||
|
function sanitizeDownloadFileName(fileName: string): string {
|
||||||
|
const trimmed = fileName.trim();
|
||||||
|
if (!trimmed) {
|
||||||
|
return "download";
|
||||||
|
}
|
||||||
|
return trimmed.replace(/[\\/:*?"<>|]+/g, "_");
|
||||||
|
}
|
||||||
|
|
||||||
|
function splitFileName(fileName: string): { base: string; ext: string } {
|
||||||
|
const lastDot = fileName.lastIndexOf(".");
|
||||||
|
if (lastDot <= 0) {
|
||||||
|
return { base: fileName, ext: "" };
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
base: fileName.slice(0, lastDot),
|
||||||
|
ext: fileName.slice(lastDot),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatSpeed(bytesPerSecond: number): string {
|
||||||
|
if (bytesPerSecond < 1024) {
|
||||||
|
return `${Math.round(bytesPerSecond)} B/s`;
|
||||||
|
}
|
||||||
|
if (bytesPerSecond < 1024 * 1024) {
|
||||||
|
return `${(bytesPerSecond / 1024).toFixed(1)} KB/s`;
|
||||||
|
}
|
||||||
|
return `${(bytesPerSecond / (1024 * 1024)).toFixed(1)} MB/s`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatEta(seconds: number): string {
|
||||||
|
if (seconds < 1) {
|
||||||
|
return "< 1s";
|
||||||
|
}
|
||||||
|
if (seconds < 60) {
|
||||||
|
return `${Math.round(seconds)}s`;
|
||||||
|
}
|
||||||
|
const mins = Math.floor(seconds / 60);
|
||||||
|
const secs = Math.round(seconds % 60);
|
||||||
|
return `${mins}m ${secs}s`;
|
||||||
|
}
|
||||||
@@ -2174,6 +2174,23 @@ export class Session {
|
|||||||
{ cwd: agent.cwd }
|
{ cwd: agent.cwd }
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Get file statuses (A=added, D=deleted, M=modified) to detect deleted files
|
||||||
|
const { stdout: nameStatusOutput } = await execAsync(
|
||||||
|
"git diff --name-status HEAD",
|
||||||
|
{ cwd: agent.cwd }
|
||||||
|
);
|
||||||
|
const deletedFiles = new Set<string>();
|
||||||
|
const addedFiles = new Set<string>();
|
||||||
|
for (const line of nameStatusOutput.trim().split("\n").filter(Boolean)) {
|
||||||
|
const [status, ...pathParts] = line.split("\t");
|
||||||
|
const path = pathParts.join("\t");
|
||||||
|
if (status === "D") {
|
||||||
|
deletedFiles.add(path);
|
||||||
|
} else if (status === "A") {
|
||||||
|
addedFiles.add(path);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Parse numstat output: "additions\tdeletions\tfilepath" or "-\t-\tfilepath" for binary
|
// Parse numstat output: "additions\tdeletions\tfilepath" or "-\t-\tfilepath" for binary
|
||||||
interface FileStats {
|
interface FileStats {
|
||||||
path: string;
|
path: string;
|
||||||
@@ -2181,6 +2198,8 @@ export class Session {
|
|||||||
deletions: number;
|
deletions: number;
|
||||||
isBinary: boolean;
|
isBinary: boolean;
|
||||||
isTracked: boolean;
|
isTracked: boolean;
|
||||||
|
isDeleted: boolean;
|
||||||
|
isNew: boolean;
|
||||||
}
|
}
|
||||||
const fileStats: FileStats[] = [];
|
const fileStats: FileStats[] = [];
|
||||||
|
|
||||||
@@ -2196,6 +2215,8 @@ export class Session {
|
|||||||
deletions: isBinary ? 0 : parseInt(delStr, 10),
|
deletions: isBinary ? 0 : parseInt(delStr, 10),
|
||||||
isBinary,
|
isBinary,
|
||||||
isTracked: true,
|
isTracked: true,
|
||||||
|
isDeleted: deletedFiles.has(path),
|
||||||
|
isNew: addedFiles.has(path),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -2224,6 +2245,8 @@ export class Session {
|
|||||||
deletions: 0,
|
deletions: 0,
|
||||||
isBinary,
|
isBinary,
|
||||||
isTracked: false,
|
isTracked: false,
|
||||||
|
isDeleted: false,
|
||||||
|
isNew: true,
|
||||||
});
|
});
|
||||||
} catch {
|
} catch {
|
||||||
// If we can't determine, assume text and try to get it
|
// If we can't determine, assume text and try to get it
|
||||||
@@ -2233,6 +2256,8 @@ export class Session {
|
|||||||
deletions: 0,
|
deletions: 0,
|
||||||
isBinary: false,
|
isBinary: false,
|
||||||
isTracked: false,
|
isTracked: false,
|
||||||
|
isDeleted: false,
|
||||||
|
isNew: true,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -2250,8 +2275,8 @@ export class Session {
|
|||||||
if (stats.isBinary) {
|
if (stats.isBinary) {
|
||||||
allFiles.push({
|
allFiles.push({
|
||||||
path: stats.path,
|
path: stats.path,
|
||||||
isNew: !stats.isTracked,
|
isNew: stats.isNew,
|
||||||
isDeleted: false,
|
isDeleted: stats.isDeleted,
|
||||||
additions: 0,
|
additions: 0,
|
||||||
deletions: 0,
|
deletions: 0,
|
||||||
hunks: [],
|
hunks: [],
|
||||||
@@ -2264,8 +2289,8 @@ export class Session {
|
|||||||
if (totalLines > MAX_DIFF_LINES) {
|
if (totalLines > MAX_DIFF_LINES) {
|
||||||
allFiles.push({
|
allFiles.push({
|
||||||
path: stats.path,
|
path: stats.path,
|
||||||
isNew: !stats.isTracked,
|
isNew: stats.isNew,
|
||||||
isDeleted: false,
|
isDeleted: stats.isDeleted,
|
||||||
additions: stats.additions,
|
additions: stats.additions,
|
||||||
deletions: stats.deletions,
|
deletions: stats.deletions,
|
||||||
hunks: [],
|
hunks: [],
|
||||||
@@ -2301,8 +2326,8 @@ export class Session {
|
|||||||
// If diff fails for this file, add it with empty hunks
|
// If diff fails for this file, add it with empty hunks
|
||||||
allFiles.push({
|
allFiles.push({
|
||||||
path: stats.path,
|
path: stats.path,
|
||||||
isNew: !stats.isTracked,
|
isNew: stats.isNew,
|
||||||
isDeleted: false,
|
isDeleted: stats.isDeleted,
|
||||||
additions: stats.additions,
|
additions: stats.additions,
|
||||||
deletions: stats.deletions,
|
deletions: stats.deletions,
|
||||||
hunks: [],
|
hunks: [],
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ import { createPaseoDaemon, type PaseoDaemonConfig } from "../bootstrap.js";
|
|||||||
type TestPaseoDaemonOptions = {
|
type TestPaseoDaemonOptions = {
|
||||||
basicUsers?: Record<string, string>;
|
basicUsers?: Record<string, string>;
|
||||||
downloadTokenTtlMs?: number;
|
downloadTokenTtlMs?: number;
|
||||||
|
corsAllowedOrigins?: string[];
|
||||||
|
listen?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type TestPaseoDaemon = {
|
export type TestPaseoDaemon = {
|
||||||
@@ -59,12 +61,13 @@ export async function createTestPaseoDaemon(
|
|||||||
const staticDir = await mkdtemp(path.join(os.tmpdir(), "paseo-static-"));
|
const staticDir = await mkdtemp(path.join(os.tmpdir(), "paseo-static-"));
|
||||||
const port = await getAvailablePort();
|
const port = await getAvailablePort();
|
||||||
|
|
||||||
|
const listenHost = options.listen ?? '127.0.0.1';
|
||||||
const config: PaseoDaemonConfig = {
|
const config: PaseoDaemonConfig = {
|
||||||
listen: `127.0.0.1:${port}`,
|
listen: `${listenHost}:${port}`,
|
||||||
paseoHome,
|
paseoHome,
|
||||||
corsAllowedOrigins: [],
|
corsAllowedOrigins: options.corsAllowedOrigins ?? [],
|
||||||
agentMcpRoute: "/mcp/agents",
|
agentMcpRoute: "/mcp/agents",
|
||||||
agentMcpAllowedHosts: [`127.0.0.1:${port}`, `localhost:${port}`],
|
agentMcpAllowedHosts: [`127.0.0.1:${port}`, `localhost:${port}`, `${listenHost}:${port}`],
|
||||||
auth: {
|
auth: {
|
||||||
basicUsers,
|
basicUsers,
|
||||||
agentMcpAuthHeader,
|
agentMcpAuthHeader,
|
||||||
|
|||||||