mirror of
https://github.com/crabwise-ai/crabwalk.git
synced 2026-08-14 00:57:52 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
77d88c543f | ||
|
|
ea99ca93fd | ||
|
|
6ca27b1e81 | ||
|
|
6a60c6e9cd | ||
|
|
1299d415b8 | ||
|
|
72a4917941 | ||
|
|
00d88aa1a8 | ||
|
|
8dbb6802e6 | ||
|
|
c76d7c3354 | ||
|
|
793d2582d9 | ||
|
|
5c59e9baa5 | ||
|
|
8d076aa9c3 | ||
|
|
56a2cf83be | ||
|
|
80067dec9a | ||
|
|
23699da927 | ||
|
|
600cfca6d6 | ||
|
|
ca6a60d1a9 | ||
|
|
6dc43b08ef | ||
|
|
43e5734833 | ||
|
|
6d3315f9c3 | ||
|
|
f2a8c1dfcb |
@@ -0,0 +1,46 @@
|
||||
# Repository Guidelines
|
||||
|
||||
## Project Structure & Module Organization
|
||||
- `src/routes/`: TanStack Start file-based routes (`/monitor`, `/workspace`, API handlers under `src/routes/api/`).
|
||||
- `src/components/`: UI by domain (`monitor/`, `workspace/`, `navigation/`, `ani/`).
|
||||
- `src/integrations/`: external/system integrations (`openclaw/`, `trpc/`, `query/`).
|
||||
- `src/lib/`: shared utilities (graph layout, workspace FS helpers, demo data).
|
||||
- `public/`: static assets (images, fonts, skill metadata).
|
||||
- Runtime and packaging files: `Dockerfile`, `docker-compose.yml`, `bin/crabwalk`.
|
||||
|
||||
## Architecture Overview
|
||||
- Stack: TanStack Start + Router, tRPC, TanStack Query/DB, ReactFlow, Tailwind v4, React 19.
|
||||
- Monitor flow: OpenClaw gateway WebSocket -> server integration (`src/integrations/openclaw/`) -> tRPC router (`src/integrations/trpc/router.ts`) -> client collections/graph UI.
|
||||
- API entrypoint: `src/routes/api/trpc.$.ts`; router setup in `src/router.tsx`.
|
||||
|
||||
## Build, Test, and Development Commands
|
||||
- `npm run dev`: starts local dev server on `http://localhost:3000`.
|
||||
- `npm run build`: creates production build with Vite/TanStack Start.
|
||||
- `npm start`: runs the built server from `.output/server/index.mjs`.
|
||||
- `docker-compose up -d`: run containerized app (set `CLAWDBOT_API_TOKEN` first).
|
||||
|
||||
## Coding Style & Naming Conventions
|
||||
- Language: TypeScript + React function components.
|
||||
- Style in current codebase: 2-space indentation, single quotes, semicolon-light formatting.
|
||||
- Components/files: `PascalCase` for React components (example: `SessionNode.tsx`).
|
||||
- Hooks/utilities: `camelCase` exports, hooks prefixed with `use` (example: `useIsMobile.ts`).
|
||||
- Route files follow TanStack conventions, e.g. `src/routes/monitor/index.tsx`, `src/routes/api/trpc.$.ts`.
|
||||
- Use path alias `~/` for imports from `src`.
|
||||
|
||||
## Testing Guidelines
|
||||
- No dedicated automated test script is currently defined in `package.json`.
|
||||
- Minimum pre-PR validation: run `npm run build`, then verify `/monitor` connectivity and `/workspace` file operations in `npm run dev`.
|
||||
- If adding tests, colocate as `*.test.ts` / `*.test.tsx` near the feature and prefer fast unit tests for parsing/state logic.
|
||||
|
||||
## Commit & Pull Request Guidelines
|
||||
- Follow Conventional Commit style used in history: `feat(scope): ...`, `fix(scope): ...`, `docs: ...`, `chore: ...`.
|
||||
- Keep scopes aligned with feature areas (`monitor`, `workspace`, `nav`, `openclaw`).
|
||||
- PRs should include:
|
||||
- clear summary of behavior changes,
|
||||
- linked issue(s) when applicable,
|
||||
- screenshots/GIFs for UI changes,
|
||||
- notes on env/config changes (tokens, gateway URL, workspace mounts).
|
||||
|
||||
## Security & Configuration Tips
|
||||
- Never commit secrets. Use `.env` or runtime env vars (`CLAWDBOT_API_TOKEN`, `CLAWDBOT_URL`).
|
||||
- Keep `.env.example` updated when introducing new required configuration.
|
||||
@@ -36,17 +36,46 @@ cp ~/.crabwalk/bin/crabwalk ~/.local/bin/
|
||||
chmod +x ~/.local/bin/crabwalk
|
||||
```
|
||||
|
||||
Then run:
|
||||
## CLI Usage
|
||||
|
||||
### Commands
|
||||
|
||||
```bash
|
||||
crabwalk # Start on 0.0.0.0:3000
|
||||
crabwalk # Start server (default: 0.0.0.0:3000)
|
||||
crabwalk start --daemon # Run in background
|
||||
crabwalk start -p 8080 # Custom port
|
||||
crabwalk stop # Stop daemon
|
||||
crabwalk stop # Stop background server
|
||||
crabwalk status # Check if running
|
||||
crabwalk update # Update to latest
|
||||
crabwalk update # Update to latest version
|
||||
```
|
||||
|
||||
### Options
|
||||
|
||||
```
|
||||
-p, --port <port> Server port (default: 3000)
|
||||
-H, --host <host> Bind address (default: 0.0.0.0)
|
||||
-g, --gateway <url> Gateway WebSocket URL (default: ws://127.0.0.1:18789)
|
||||
-t, --token <token> Gateway auth token
|
||||
-d, --daemon Run in background
|
||||
-v, --version Show version
|
||||
```
|
||||
|
||||
### Examples
|
||||
|
||||
```bash
|
||||
crabwalk -p 8080 # Custom port
|
||||
crabwalk -t mytoken123 # Explicit token
|
||||
crabwalk -g ws://192.168.1.50:18789 # Remote gateway
|
||||
crabwalk start -d -p 8080 # Daemon on port 8080
|
||||
```
|
||||
|
||||
### Auto-detection
|
||||
|
||||
The CLI automatically detects your gateway token from `~/.openclaw/openclaw.json` - no config needed if you're running OpenClaw locally.
|
||||
|
||||
### QR Code
|
||||
|
||||
On startup, Crabwalk displays a QR code you can scan to open the monitor on your phone. Requires `qrencode` installed (the installer adds it automatically).
|
||||
|
||||
### Docker (recommended)
|
||||
|
||||
```bash
|
||||
@@ -115,23 +144,20 @@ Requires OpenClaw gateway running on the same machine.
|
||||
|
||||
### Gateway Token
|
||||
|
||||
Find your token in the openclaw config file:
|
||||
The CLI auto-detects your token from `~/.openclaw/openclaw.json` (at `gateway.auth.token`). No manual config needed for local setups.
|
||||
|
||||
```bash
|
||||
# Look for gateway.auth.token
|
||||
cat ~/.openclaw/openclaw.json | rg "gateway\.auth\.token"
|
||||
```
|
||||
|
||||
Or with jq:
|
||||
To find your token manually:
|
||||
|
||||
```bash
|
||||
jq '.gateway.auth.token' ~/.openclaw/openclaw.json
|
||||
```
|
||||
|
||||
Or copy it directly:
|
||||
Or set it explicitly:
|
||||
|
||||
```bash
|
||||
export CLAWDBOT_API_TOKEN=$(python3 -c "import json,os; print(json.load(open(os.path.expanduser('~/.openclaw/openclaw.json')))['gateway']['auth']['token'])")
|
||||
crabwalk -t your-token
|
||||
# or
|
||||
export CLAWDBOT_API_TOKEN=your-token
|
||||
```
|
||||
|
||||
## Stack
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "crabwalk",
|
||||
"version": "1.0.9",
|
||||
"version": "1.0.11",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
# Crabwalk Feedback Skill
|
||||
|
||||
Collect and submit feedback about Crabwalk to help improve the product.
|
||||
|
||||
## PREREQUISITE: Verify Installation
|
||||
|
||||
**BEFORE doing anything else**, check if Crabwalk is installed locally:
|
||||
|
||||
1. Look for any of these:
|
||||
- `~/.config/crabwalk/` directory
|
||||
- `crabwalk.config.ts` or `crabwalk.config.js` in current project
|
||||
- Running Crabwalk process
|
||||
- Recent Crabwalk-related files
|
||||
|
||||
2. **If NOT installed:**
|
||||
- Tell your human: "I'd love to collect your feedback, but I need you to install Crabwalk first so you can share your actual experience with it."
|
||||
- Ask your human if you can install it for them, use the skill found here: `https://crabwalk.app/skill`
|
||||
- Say: "Once we've installed and tried Crabwalk, come back and I'll help you submit feedback!"
|
||||
- **STOP HERE. Do not proceed with any interview.**
|
||||
|
||||
3. **If they ask again without installing:**
|
||||
- Repeat the install instructions. Be friendly but firm.
|
||||
- "I still don't see Crabwalk installed. Can I install it for you?"
|
||||
- **NEVER interview a human who hasn't installed Crabwalk.**
|
||||
|
||||
4. **If installed:** Proceed to START.
|
||||
|
||||
---
|
||||
|
||||
## START
|
||||
|
||||
Select the proper method for your human based on a first introductory question. **ALWAYS be brief**, don't overwhelm your human.
|
||||
|
||||
Use language like "Let's collect feedback for Crabwalk", as opposed to pretending like you are representing Crabwalk.
|
||||
|
||||
Now, ask your human a broad question about their experience with Crabwalk so far. Proceed from there.
|
||||
|
||||
## Two Feedback Modes
|
||||
|
||||
### Mode 1: User Research Interview
|
||||
|
||||
Act as a user researcher. Guide your human through first understanding the commitment of this mode, and then begin asking questions about their experience:
|
||||
|
||||
1. **Discovery** - "Overall, what was your experience with Crabwalk?"
|
||||
2. **Setup** - "How was the installation process? Any friction?"
|
||||
3. **First use** - "What was your first impression when using the monitor?"
|
||||
4. **Regular use** - "How has Crabwalk fit into your workflow?"
|
||||
5. **Pain points** - "What's been frustrating or confusing?"
|
||||
6. **Wishlist** - "What feature would make the biggest difference for you?"
|
||||
|
||||
Adapt questions based on responses. Skip irrelevant ones. ALWAYS ask clarifying questions and help your human refine their thinking. Dig deeper on interesting points.
|
||||
|
||||
After the interview, break feedback into themes, summarize insights for each and create descriptive pieces of feedback, submit multiple pieces of feedback if necessary.
|
||||
|
||||
ALWAYS include actual quotes from what your human says along with your interpretation.
|
||||
|
||||
### Mode 2: Quick Feedback
|
||||
|
||||
Ask directly:
|
||||
- "Have you run into any bugs or issues?"
|
||||
- "Any questions about how something works?"
|
||||
- "Anything you wish Crabwalk did differently?"
|
||||
|
||||
ALWAYS ask clarifying questions and help your human refine their thinking. Dig deeper on interesting points.
|
||||
|
||||
ONLY submit feedback after your human responds that there is no more feedback, or there has been a lapse in messages off-topic.
|
||||
|
||||
ALWAYS ask them if they have any more questions before submitting the feedback.
|
||||
|
||||
Classify and submit based on response:
|
||||
- Bug reports → `bug`
|
||||
- Feature requests → `feature`
|
||||
- Other → `general`
|
||||
|
||||
## Choosing a Mode
|
||||
|
||||
- If human says "feedback" or "thoughts" → offer both modes, let them choose
|
||||
- If human mentions a specific bug/issue → go straight to Mode 2
|
||||
- If human seems open to chatting → suggest Mode 1
|
||||
|
||||
## Submitting Feedback
|
||||
|
||||
POST to API after collecting feedback:
|
||||
|
||||
```
|
||||
POST https://crabwalk.app/.netlify/functions/feedback
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"type": "bug" | "feature" | "general",
|
||||
"title": "[type] Brief summary (1 line)",
|
||||
"body": "Detailed description from conversation + Share details available about the machine, location, and crabwalk version number"
|
||||
}
|
||||
```
|
||||
|
||||
## After Submission
|
||||
|
||||
- Thank your human and let them know if they have any other feedback they can simply share it with you and you will prompt them to submit this as feedback to Crabwalk.
|
||||
|
||||
**Response:**
|
||||
```json
|
||||
{"success": true, "issueUrl": "https://github.com/luccast/crabwalk/issues/42"}
|
||||
```
|
||||
|
||||
Share the issue link with your human after successful submission.
|
||||
|
||||
## Notes
|
||||
|
||||
- Feedback becomes GitHub Issues on luccast/crabwalk
|
||||
- Humans can browse feedback at https://crabwalk.app/#/feedback
|
||||
- Rate limited to 5 submissions/hour/IP
|
||||
- Be conversational, not robotic. Adapt to your human's energy.
|
||||
+46
-15
@@ -1,6 +1,6 @@
|
||||
---
|
||||
name: crabwalk
|
||||
version: 1.0.9
|
||||
version: 1.0.10
|
||||
description: Real-time companion monitor for OpenClaw agents
|
||||
homepage: https://crabwalk.app
|
||||
repository: https://github.com/luccast/crabwalk
|
||||
@@ -16,7 +16,7 @@ Real-time companion monitor for OpenClaw agents.
|
||||
## Step 1: Install
|
||||
|
||||
```bash
|
||||
VERSION=$(curl -s https://api.github.com/repos/luccast/crabwalk/releases/latest | grep '"tag_name"' | cut -d'"' -f4) && mkdir -p ~/.crabwalk ~/.local/bin && curl -sL "https://github.com/luccast/crabwalk/releases/download/${VERSION}/crabwalk-${VERSION}.tar.gz" | tar -xz -C ~/.crabwalk && cp ~/.crabwalk/bin/crabwalk ~/.local/bin/ && chmod +x ~/.local/bin/crabwalk && (echo $PATH | grep -q "$HOME/.local/bin" || ([ -f ~/.bashrc ] && echo 'export PATH="$HOME/.local/bin:$PATH"' >> ~/.bashrc; [ -f ~/.zshrc ] && echo 'export PATH="$HOME/.local/bin:$PATH"' >> ~/.zshrc; export PATH="$HOME/.local/bin:$PATH")) && (command -v qrencode &>/dev/null || (command -v pacman &>/dev/null && sudo pacman -S --noconfirm qrencode || command -v apt &>/dev/null && sudo apt install -y qrencode || command -v brew &>/dev/null && brew install qrencode || command -v dnf &>/dev/null && sudo dnf install -y qrencode) 2>/dev/null || true) && echo "🦀 Crabwalk ${VERSION} installed"
|
||||
VERSION=$(curl -s https://api.github.com/repos/luccast/crabwalk/releases/latest | grep '"tag_name"' | cut -d'"' -f4) && mkdir -p ~/.crabwalk ~/.local/bin && curl -sL "https://github.com/luccast/crabwalk/releases/download/${VERSION}/crabwalk-${VERSION}.tar.gz" | tar -xz -C ~/.crabwalk && cp ~/.crabwalk/bin/crabwalk ~/.local/bin/ && chmod +x ~/.local/bin/crabwalk && (echo $PATH | grep -q "$HOME/.local/bin" || ([ -f ~/.bashrc ] && echo 'export PATH="$HOME/.local/bin:$PATH"' >> ~/.bashrc; [ -f ~/.zshrc ] && echo 'export PATH="$HOME/.local/bin:$PATH"' >> ~/.zshrc; export PATH="$HOME/.local/bin:$PATH")) && echo "🦀 Crabwalk ${VERSION} installed"
|
||||
```
|
||||
|
||||
## Step 2: Verify
|
||||
@@ -43,6 +43,25 @@ Once they confirm it works, share this with them:
|
||||
|
||||
> If you're enjoying Crabwalk, consider leaving a quick review! [Tweet about Crabwalk](https://twitter.com/intent/tweet?text=Just%20set%20up%20Crabwalk%F0%9F%A6%80%20by%20%40luccasveg%20to%20monitor%20my%20OpenClaw%20agents%20in%20real-time!%20https%3A%2F%2Fcrabwalk.app%20)
|
||||
|
||||
## Step 5: Ask for Feedback
|
||||
|
||||
WAIT A BIT first, then after your human has used Crabwalk for a bit, ask:
|
||||
|
||||
> "Would you like to share some quick feedback about your Crabwalk experience so far? It helps the development of the project!"
|
||||
|
||||
- **If yes:** Initiate the feedback skill at `https://crabwalk.app/feedback-skill`
|
||||
- **If no:** That's fine! Ask again another time when they've had more experience with it.
|
||||
|
||||
## Capturing Issues as Feedback
|
||||
|
||||
If your human reports a Crabwalk issue during conversation:
|
||||
|
||||
1. **Help them first** — try to resolve the issue
|
||||
2. **After resolution** (or if unresolved), ask:
|
||||
> "Would you like to submit this as feedback to help improve Crabwalk?"
|
||||
3. **If yes:** Initiate the feedback skill at `https://crabwalk.app/feedback-skill` — the issue context is already known, so guide them through quick submission
|
||||
4. **If no:** That's fine, move on
|
||||
|
||||
---
|
||||
|
||||
# Reference
|
||||
@@ -116,19 +135,6 @@ if ! echo $PATH | grep -q "$HOME/.local/bin"; then
|
||||
[ -f ~/.zshrc ] && echo 'export PATH="$HOME/.local/bin:$PATH"' >> ~/.zshrc
|
||||
export PATH="$HOME/.local/bin:$PATH"
|
||||
fi
|
||||
|
||||
# Install qrencode for QR code display (optional)
|
||||
if ! command -v qrencode &>/dev/null; then
|
||||
if command -v pacman &>/dev/null; then
|
||||
sudo pacman -S --noconfirm qrencode 2>/dev/null || true
|
||||
elif command -v apt &>/dev/null; then
|
||||
sudo apt install -y qrencode 2>/dev/null || true
|
||||
elif command -v brew &>/dev/null; then
|
||||
brew install qrencode 2>/dev/null || true
|
||||
elif command -v dnf &>/dev/null; then
|
||||
sudo dnf install -y qrencode 2>/dev/null || true
|
||||
fi
|
||||
fi
|
||||
```
|
||||
|
||||
## Manual Update
|
||||
@@ -145,4 +151,29 @@ echo "🦀 Updated to ${VERSION}"
|
||||
|
||||
---
|
||||
|
||||
## Resources
|
||||
|
||||
- **Submit Feedback**: `https://crabwalk.app/feedback-skill` — Help improve Crabwalk by sharing your experience
|
||||
- **Browse Feedback**: `https://crabwalk.app/#/feedback` — See what others are saying
|
||||
|
||||
## Optional: QR Code Display
|
||||
|
||||
Install `qrencode` to display QR codes for easy mobile access:
|
||||
|
||||
```bash
|
||||
# macOS
|
||||
brew install qrencode
|
||||
|
||||
# Debian/Ubuntu
|
||||
sudo apt install qrencode
|
||||
|
||||
# Fedora
|
||||
sudo dnf install qrencode
|
||||
|
||||
# Arch
|
||||
sudo pacman -S qrencode
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
Repository: https://github.com/luccast/crabwalk
|
||||
|
||||
@@ -190,7 +190,7 @@ export const ActionNode = memo(function ActionNode({
|
||||
)}
|
||||
|
||||
{data.toolName && (
|
||||
<div className="font-console text-[10px] text-neon-lavender mb-1.5">
|
||||
<div className="font-console text-[11px] text-neon-lavender mb-1.5">
|
||||
<span className="text-shell-500">tool:</span> {data.toolName}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -132,7 +132,7 @@ export const ExecNode = memo(function ExecNode({ data, selected }: ExecNodeProps
|
||||
</span>
|
||||
<span
|
||||
className={`
|
||||
ml-1 px-2 py-0.5 rounded-md border text-[10px] font-console truncate max-w-[220px]
|
||||
ml-1 px-2 py-0.5 rounded-md border text-[11px] font-console truncate max-w-[220px]
|
||||
border-shell-700 ${status.badgeColor}
|
||||
`}
|
||||
title={data.command}
|
||||
@@ -201,7 +201,7 @@ export const ExecNode = memo(function ExecNode({ data, selected }: ExecNodeProps
|
||||
</div>
|
||||
|
||||
{data.outputTruncated && (
|
||||
<div className="mb-1.5 text-[10px] font-console text-neon-peach">
|
||||
<div className="mb-1.5 text-[11px] font-console text-neon-peach">
|
||||
output truncated
|
||||
</div>
|
||||
)}
|
||||
@@ -214,7 +214,7 @@ export const ExecNode = memo(function ExecNode({ data, selected }: ExecNodeProps
|
||||
|
||||
{hasOutput && expanded && (
|
||||
<div className="mt-1.5 border border-shell-800 rounded bg-shell-950/60 max-h-[320px] overflow-auto">
|
||||
<div className="sticky top-0 z-10 flex items-center justify-between px-2 py-1 text-[10px] font-console text-shell-500 bg-shell-950/90 border-b border-shell-800">
|
||||
<div className="sticky top-0 z-10 flex items-center justify-between px-2 py-1 text-[11px] font-console text-shell-500 bg-shell-950/90 border-b border-shell-800">
|
||||
<span>{status.label}</span>
|
||||
<span>{data.outputs.length} chunks</span>
|
||||
</div>
|
||||
@@ -224,7 +224,7 @@ export const ExecNode = memo(function ExecNode({ data, selected }: ExecNodeProps
|
||||
key={chunk.id}
|
||||
className={`border rounded px-2 py-1 ${streamStyle(chunk.stream)}`}
|
||||
>
|
||||
<div className="flex items-center gap-2 mb-1 text-[10px] font-console text-shell-500">
|
||||
<div className="flex items-center gap-2 mb-1 text-[11px] font-console text-shell-500">
|
||||
<span className={chunk.stream === 'stderr' ? 'text-crab-300' : 'text-neon-cyan'}>
|
||||
{chunk.stream}
|
||||
</span>
|
||||
|
||||
@@ -39,7 +39,7 @@ export function MobileMonitorToolbar({
|
||||
>
|
||||
<PanelLeft size={22} />
|
||||
{sessionCount > 0 && (
|
||||
<span className="absolute top-1.5 right-1.5 min-w-[18px] h-[18px] px-1 flex items-center justify-center bg-crab-600 text-white text-[10px] font-display rounded-full">
|
||||
<span className="absolute top-1.5 right-1.5 min-w-[18px] h-[18px] px-1 flex items-center justify-center bg-crab-600 text-white text-[11px] font-display rounded-full">
|
||||
{sessionCount > 99 ? '99+' : sessionCount}
|
||||
</span>
|
||||
)}
|
||||
@@ -56,7 +56,7 @@ export function MobileMonitorToolbar({
|
||||
<div className="w-px h-4 bg-shell-700" />
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="font-display text-sm text-neon-peach">{actionCount}</span>
|
||||
<span className="font-console text-[10px] text-shell-500 uppercase">acts</span>
|
||||
<span className="font-console text-[11px] text-shell-500 uppercase">acts</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -67,7 +67,7 @@ export function MobileMonitorToolbar({
|
||||
className="relative p-3 min-w-[48px] min-h-[48px] rounded-lg active:bg-shell-800 text-gray-400 active:text-crab-400 transition-colors"
|
||||
>
|
||||
<Trash2 size={22} />
|
||||
<span className="absolute top-1.5 right-1.5 min-w-[18px] h-[18px] px-1 flex items-center justify-center bg-shell-700 text-shell-300 text-[10px] font-display rounded-full">
|
||||
<span className="absolute top-1.5 right-1.5 min-w-[18px] h-[18px] px-1 flex items-center justify-center bg-shell-700 text-shell-300 text-[11px] font-display rounded-full">
|
||||
{completedCount > 99 ? '99+' : completedCount}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
@@ -57,7 +57,7 @@ function SubagentItem({
|
||||
: 'active:bg-shell-800/30 border-l-2 border-l-transparent'
|
||||
}`}
|
||||
>
|
||||
<div className="font-display text-[9px] font-medium text-neon-cyan/60 uppercase tracking-widest mb-1">
|
||||
<div className="font-display text-[11px] font-medium text-neon-cyan/60 uppercase tracking-widest mb-1">
|
||||
subagent
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
@@ -239,7 +239,7 @@ export function MobileSessionDrawer({
|
||||
: 'active:bg-shell-800/50 border-l-2 border-l-transparent'
|
||||
}`}
|
||||
>
|
||||
<div className="font-display text-[9px] font-medium text-shell-500 uppercase tracking-widest mb-1">
|
||||
<div className="font-display text-[11px] font-medium text-shell-500 uppercase tracking-widest mb-1">
|
||||
main
|
||||
</div>
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
|
||||
@@ -75,7 +75,7 @@ function SubagentItem({
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="font-display text-[9px] font-medium text-neon-cyan/60 uppercase tracking-widest mb-1">
|
||||
<div className="font-display text-[11px] font-medium text-neon-cyan/60 uppercase tracking-widest mb-1">
|
||||
subagent
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
@@ -271,7 +271,7 @@ export function SessionList({
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="font-display text-[9px] font-medium text-shell-500 uppercase tracking-widest mb-1">
|
||||
<div className="font-display text-[11px] font-medium text-shell-500 uppercase tracking-widest mb-1">
|
||||
main
|
||||
</div>
|
||||
<div className="flex items-center gap-2 mb-1.5">
|
||||
|
||||
@@ -138,7 +138,7 @@ export const SessionNode = memo(function SessionNode({
|
||||
</div>
|
||||
|
||||
{relativeTime && (
|
||||
<div className="flex items-center gap-1 mt-2 font-console text-[10px] text-shell-500">
|
||||
<div className="flex items-center gap-1 mt-2 font-console text-[11px] text-shell-500">
|
||||
<Clock size={10} className="text-shell-600" />
|
||||
<span>{relativeTime}</span>
|
||||
</div>
|
||||
|
||||
@@ -12,6 +12,7 @@ interface SettingsPanelProps {
|
||||
persistenceStartedAt: number | null
|
||||
persistenceSessionCount: number
|
||||
persistenceActionCount: number
|
||||
gatewayEndpoint: string
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
onHistoricalModeChange: (enabled: boolean) => void
|
||||
@@ -37,6 +38,7 @@ export function SettingsPanel({
|
||||
persistenceStartedAt,
|
||||
persistenceSessionCount,
|
||||
persistenceActionCount,
|
||||
gatewayEndpoint,
|
||||
open,
|
||||
onOpenChange,
|
||||
onHistoricalModeChange,
|
||||
@@ -148,7 +150,7 @@ export function SettingsPanel({
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<p className="font-console text-[10px] text-shell-500 mb-4">
|
||||
<p className="font-console text-[11px] text-shell-500 mb-4">
|
||||
<span className="text-crab-600">></span> log raw events to terminal
|
||||
</p>
|
||||
|
||||
@@ -173,17 +175,17 @@ export function SettingsPanel({
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<p className="font-console text-[10px] text-shell-500 mb-3">
|
||||
<p className="font-console text-[11px] text-shell-500 mb-3">
|
||||
<span className="text-crab-600">></span> persist data across refreshes
|
||||
</p>
|
||||
|
||||
{persistenceEnabled && persistenceStartedAt && (
|
||||
<div className="font-console text-[10px] text-neon-mint mb-2">
|
||||
<div className="font-console text-[11px] text-neon-mint mb-2">
|
||||
<span className="text-crab-600">></span> running since {new Date(persistenceStartedAt).toLocaleTimeString()}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="font-console text-[10px] text-shell-400 mb-3 space-y-1">
|
||||
<div className="font-console text-[11px] text-shell-400 mb-3 space-y-1">
|
||||
<div>
|
||||
<span className="text-crab-600">></span> {persistenceSessionCount} sessions
|
||||
</div>
|
||||
@@ -232,7 +234,7 @@ export function SettingsPanel({
|
||||
</div>
|
||||
<button
|
||||
onClick={() => onHistoricalModeChange(!historicalMode)}
|
||||
className={`px-3 py-1 font-display text-[10px] uppercase tracking-wide rounded transition-all ${
|
||||
className={`px-3 py-1 font-display text-[11px] uppercase tracking-wide rounded transition-all ${
|
||||
historicalMode
|
||||
? 'bg-neon-cyan/20 text-neon-cyan'
|
||||
: 'bg-shell-800 text-gray-500 hover:bg-shell-700'
|
||||
@@ -241,7 +243,7 @@ export function SettingsPanel({
|
||||
{historicalMode ? 'On' : 'Off'}
|
||||
</button>
|
||||
</div>
|
||||
<p className="font-console text-[10px] text-shell-500">
|
||||
<p className="font-console text-[11px] text-shell-500">
|
||||
<span className="text-crab-600">></span> fetch 24h of sessions from gateway on refresh
|
||||
</p>
|
||||
</div>
|
||||
@@ -256,12 +258,12 @@ export function SettingsPanel({
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<p className="font-console text-[10px] text-shell-500 mb-3">
|
||||
<p className="font-console text-[11px] text-shell-500 mb-3">
|
||||
<span className="text-crab-600">></span> collect raw events for export
|
||||
</p>
|
||||
|
||||
{logCount > 0 && (
|
||||
<div className="font-console text-[10px] text-neon-mint mb-3">
|
||||
<div className="font-console text-[11px] text-neon-mint mb-3">
|
||||
<span className="text-crab-600">></span> {logCount} events collected
|
||||
</div>
|
||||
)}
|
||||
@@ -303,9 +305,9 @@ export function SettingsPanel({
|
||||
<span className="text-crab-600">❮</span> Gateway Info <span className="text-crab-600">❯</span>
|
||||
</h3>
|
||||
|
||||
<div className="font-console text-[10px] text-shell-500 space-y-1.5">
|
||||
<div className="font-console text-[11px] text-shell-500 space-y-1.5">
|
||||
<div>
|
||||
<span className="text-crab-600">></span> endpoint: ws://127.0.0.1:18789
|
||||
<span className="text-crab-600">></span> endpoint: {gatewayEndpoint}
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-crab-600">></span> protocol: v3
|
||||
@@ -323,7 +325,7 @@ export function SettingsPanel({
|
||||
{/* Version badge */}
|
||||
<div className="flex items-center justify-center gap-2 pt-4">
|
||||
<span className="w-2 h-2 rounded-full bg-neon-mint animate-pulse" />
|
||||
<span className="font-console text-[10px] text-shell-500">
|
||||
<span className="font-console text-[11px] text-shell-500">
|
||||
crabwalk v{version}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -99,7 +99,7 @@ export function NavTabs() {
|
||||
initial={{ opacity: 0, y: -8, scale: 0.96 }}
|
||||
animate={{ opacity: 1, y: 0, scale: 1 }}
|
||||
exit={{ opacity: 0, y: -8, scale: 0.96 }}
|
||||
transition={{ type: 'spring', damping: 25, stiffness: 400 }}
|
||||
transition={{ duration: 0.15, ease: 'easeOut' }}
|
||||
className="absolute top-full left-0 mt-2 z-50 min-w-[200px]"
|
||||
>
|
||||
{/* Terminal-style container */}
|
||||
@@ -119,11 +119,8 @@ export function NavTabs() {
|
||||
tab.path === currentPath || currentPath.startsWith(tab.path)
|
||||
|
||||
return (
|
||||
<motion.button
|
||||
<button
|
||||
key={tab.path}
|
||||
initial={{ opacity: 0, x: -10 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
transition={{ delay: index * 0.05 }}
|
||||
onClick={() => handleSelect(tab.path)}
|
||||
className={`w-full flex items-center gap-3 px-3 py-2.5 rounded-md transition-all group ${
|
||||
isActive
|
||||
@@ -147,12 +144,9 @@ export function NavTabs() {
|
||||
|
||||
{/* Active indicator */}
|
||||
{isActive && (
|
||||
<motion.div
|
||||
layoutId="nav-dropdown-active"
|
||||
className="w-1.5 h-1.5 rounded-full bg-crab-500"
|
||||
/>
|
||||
<div className="w-1.5 h-1.5 rounded-full bg-crab-500" />
|
||||
)}
|
||||
</motion.button>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
import { useEffect } from 'react'
|
||||
import { motion, AnimatePresence } from 'framer-motion'
|
||||
import { AlertTriangle, X, Check } from 'lucide-react'
|
||||
|
||||
interface ConfirmationDialogProps {
|
||||
open: boolean
|
||||
title: string
|
||||
message: string
|
||||
confirmText?: string
|
||||
cancelText?: string
|
||||
onConfirm: () => void
|
||||
onCancel: () => void
|
||||
variant?: 'danger' | 'warning' | 'info'
|
||||
}
|
||||
|
||||
export function ConfirmationDialog({
|
||||
open,
|
||||
title,
|
||||
message,
|
||||
confirmText = 'Confirm',
|
||||
cancelText = 'Cancel',
|
||||
onConfirm,
|
||||
onCancel,
|
||||
variant = 'danger',
|
||||
}: ConfirmationDialogProps) {
|
||||
const iconColor = variant === 'danger' ? 'text-crab-400' : variant === 'warning' ? 'text-neon-peach' : 'text-neon-cyan'
|
||||
const iconBg = variant === 'danger' ? 'bg-crab-900/30' : variant === 'warning' ? 'bg-neon-peach/10' : 'bg-neon-cyan/10'
|
||||
const iconBorder = variant === 'danger' ? 'border-crab-700/50' : variant === 'warning' ? 'border-neon-peach/30' : 'border-neon-cyan/30'
|
||||
const confirmButtonClass = variant === 'danger'
|
||||
? 'bg-crab-600 hover:bg-crab-500 text-white'
|
||||
: 'bg-neon-mint hover:bg-neon-mint/90 text-shell-950'
|
||||
|
||||
// Handle Escape key
|
||||
useEffect(() => {
|
||||
const handleEscape = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') onCancel()
|
||||
}
|
||||
if (open) {
|
||||
document.addEventListener('keydown', handleEscape)
|
||||
}
|
||||
return () => document.removeEventListener('keydown', handleEscape)
|
||||
}, [open, onCancel])
|
||||
|
||||
return (
|
||||
<AnimatePresence>
|
||||
{open && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
|
||||
{/* Backdrop */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
className="absolute inset-0 bg-black/60 backdrop-blur-sm"
|
||||
onClick={onCancel}
|
||||
/>
|
||||
|
||||
{/* Dialog */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.95, y: 10 }}
|
||||
animate={{ opacity: 1, scale: 1, y: 0 }}
|
||||
exit={{ opacity: 0, scale: 0.95, y: 10 }}
|
||||
transition={{ type: 'spring', duration: 0.3 }}
|
||||
className="relative w-full max-w-md bg-shell-900 rounded-xl border border-shell-700 shadow-2xl overflow-hidden"
|
||||
>
|
||||
{/* Header */}
|
||||
<div className={`flex items-center gap-3 px-5 py-4 border-b border-shell-800 ${iconBg}`}>
|
||||
<div className={`p-2 rounded-lg ${iconBg} border ${iconBorder}`}>
|
||||
<AlertTriangle size={20} className={iconColor} />
|
||||
</div>
|
||||
<h3 className="font-display text-lg text-gray-200">{title}</h3>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="px-5 py-4">
|
||||
<p className="font-console text-sm text-shell-500 leading-relaxed">
|
||||
{message}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="flex items-center justify-end gap-3 px-5 py-4 border-t border-shell-800 bg-shell-900/50">
|
||||
<button
|
||||
onClick={onCancel}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-shell-800 hover:bg-shell-700 rounded-lg text-sm font-console text-gray-300 transition-colors border border-shell-700"
|
||||
>
|
||||
<X size={14} />
|
||||
{cancelText}
|
||||
</button>
|
||||
<button
|
||||
onClick={onConfirm}
|
||||
className={`flex items-center gap-2 px-4 py-2 rounded-lg text-sm font-console transition-colors border ${confirmButtonClass}`}
|
||||
>
|
||||
<Check size={14} />
|
||||
{confirmText}
|
||||
</button>
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
)
|
||||
}
|
||||
|
||||
export default ConfirmationDialog
|
||||
@@ -0,0 +1,151 @@
|
||||
import React from 'react'
|
||||
import { motion, AnimatePresence } from 'framer-motion'
|
||||
|
||||
interface ContextMenuItem {
|
||||
icon: React.ReactNode
|
||||
label: string
|
||||
onClick: () => void
|
||||
danger?: boolean
|
||||
}
|
||||
|
||||
interface FileContextMenuProps {
|
||||
open: boolean
|
||||
position: { x: number; y: number } | null
|
||||
items: ContextMenuItem[]
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
export function FileContextMenu({ open, position, items, onClose }: FileContextMenuProps) {
|
||||
// Calculate position that stays within viewport
|
||||
const adjustedPosition = React.useMemo(() => {
|
||||
if (!position) return null
|
||||
const menuWidth = 192 // w-48 = 12rem = 192px
|
||||
const menuHeight = items.length * 42 // approximate height based on item count
|
||||
const padding = 8
|
||||
const viewportWidth = window.innerWidth
|
||||
const viewportHeight = window.innerHeight
|
||||
|
||||
let x = position.x
|
||||
let y = position.y
|
||||
|
||||
// Adjust if menu would go off right edge
|
||||
if (x + menuWidth + padding > viewportWidth) {
|
||||
x = Math.max(padding, viewportWidth - menuWidth - padding)
|
||||
}
|
||||
|
||||
// Adjust if menu would go off bottom edge
|
||||
if (y + menuHeight + padding > viewportHeight) {
|
||||
y = Math.max(padding, viewportHeight - menuHeight - padding)
|
||||
}
|
||||
|
||||
return { x, y }
|
||||
}, [position, items.length])
|
||||
|
||||
// Close menu when clicking outside or pressing Escape
|
||||
React.useEffect(() => {
|
||||
const handleEscape = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') onClose()
|
||||
}
|
||||
const handleClick = () => onClose()
|
||||
|
||||
if (open) {
|
||||
document.addEventListener('click', handleClick)
|
||||
document.addEventListener('keydown', handleEscape)
|
||||
}
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('click', handleClick)
|
||||
document.removeEventListener('keydown', handleEscape)
|
||||
}
|
||||
}, [open, onClose])
|
||||
|
||||
// Prevent clicks inside menu from closing it
|
||||
const handleMenuClick = (e: React.MouseEvent) => {
|
||||
e.stopPropagation()
|
||||
}
|
||||
|
||||
return (
|
||||
<AnimatePresence>
|
||||
{open && adjustedPosition && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.95 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
exit={{ opacity: 0, scale: 0.95 }}
|
||||
transition={{ type: 'spring', duration: 0.15 }}
|
||||
className="fixed z-50 bg-shell-900 rounded-lg border border-shell-700 shadow-xl overflow-hidden"
|
||||
style={{
|
||||
left: adjustedPosition.x,
|
||||
top: adjustedPosition.y,
|
||||
}}
|
||||
onClick={handleMenuClick}
|
||||
>
|
||||
{/* Menu items */}
|
||||
<div className="py-1">
|
||||
{items.map((item) => (
|
||||
<button
|
||||
key={item.label}
|
||||
onClick={() => {
|
||||
item.onClick()
|
||||
onClose()
|
||||
}}
|
||||
className={`w-full flex items-center gap-3 px-4 py-2.5 text-left font-console text-sm transition-colors ${
|
||||
item.danger
|
||||
? 'text-crab-400 hover:bg-crab-900/30'
|
||||
: 'text-gray-300 hover:bg-shell-800 hover:text-gray-100'
|
||||
}`}
|
||||
>
|
||||
<span className={item.danger ? 'text-crab-500' : 'text-shell-500'}>
|
||||
{item.icon}
|
||||
</span>
|
||||
{item.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
)
|
||||
}
|
||||
|
||||
// Icon components
|
||||
export const FileIcon = ({ size = 14 }: { size?: number }) => (
|
||||
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
|
||||
<polyline points="14 2 14 8 20 8" />
|
||||
<line x1="16" y1="13" x2="8" y2="13" />
|
||||
<line x1="16" y1="17" x2="8" y2="17" />
|
||||
<polyline points="10 9 9 9 8 9" />
|
||||
</svg>
|
||||
)
|
||||
|
||||
export const TrashIcon = ({ size = 14 }: { size?: number }) => (
|
||||
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round">
|
||||
<polyline points="3 6 5 6 21 6" />
|
||||
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2" />
|
||||
</svg>
|
||||
)
|
||||
|
||||
export const CopyIcon = ({ size = 14 }: { size?: number }) => (
|
||||
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round">
|
||||
<rect x="9" y="9" width="13" height="13" rx="2" ry="2" />
|
||||
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1" />
|
||||
</svg>
|
||||
)
|
||||
|
||||
export const EditIcon = ({ size = 14 }: { size?: number }) => (
|
||||
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7" />
|
||||
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z" />
|
||||
</svg>
|
||||
)
|
||||
|
||||
export const NewFileIcon = ({ size = 14 }: { size?: number }) => (
|
||||
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
|
||||
<polyline points="14 2 14 8 20 8" />
|
||||
<line x1="12" y1="18" x2="12" y2="12" />
|
||||
<line x1="9" y1="15" x2="15" y2="15" />
|
||||
</svg>
|
||||
)
|
||||
|
||||
export default FileContextMenu
|
||||
@@ -0,0 +1,439 @@
|
||||
import { useState, useCallback, useEffect, useRef } from 'react'
|
||||
import { motion, AnimatePresence } from 'framer-motion'
|
||||
import {
|
||||
FileText,
|
||||
Edit2,
|
||||
Save,
|
||||
X,
|
||||
AlertCircle,
|
||||
Check,
|
||||
} from 'lucide-react'
|
||||
import ReactMarkdown from 'react-markdown'
|
||||
|
||||
interface FileEditorProps {
|
||||
content: string
|
||||
fileName: string
|
||||
filePath?: string
|
||||
fileSize?: number
|
||||
fileModified?: Date
|
||||
error?: string
|
||||
isStarred?: boolean
|
||||
onStar?: (path: string) => void
|
||||
onSave?: (content: string, callback: (success: boolean) => void) => void
|
||||
}
|
||||
|
||||
function formatFileSize(bytes: number | undefined): string {
|
||||
if (bytes === undefined) return ''
|
||||
if (bytes === 0) return '0 B'
|
||||
const k = 1024
|
||||
const sizes = ['B', 'KB', 'MB', 'GB']
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k))
|
||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i]
|
||||
}
|
||||
|
||||
function formatModifiedDate(date: Date | undefined): string {
|
||||
if (!date) return ''
|
||||
const d = new Date(date)
|
||||
const now = new Date()
|
||||
const diff = now.getTime() - d.getTime()
|
||||
const seconds = Math.floor(diff / 1000)
|
||||
const minutes = Math.floor(seconds / 60)
|
||||
const hours = Math.floor(minutes / 60)
|
||||
const days = Math.floor(hours / 24)
|
||||
|
||||
if (days > 30) {
|
||||
return d.toLocaleDateString()
|
||||
} else if (days > 0) {
|
||||
return `${days}d ago`
|
||||
} else if (hours > 0) {
|
||||
return `${hours}h ago`
|
||||
} else if (minutes > 0) {
|
||||
return `${minutes}m ago`
|
||||
} else {
|
||||
return 'just now'
|
||||
}
|
||||
}
|
||||
|
||||
function Star({ size = 16, fill = 'none', className = '' }: { size?: number; fill?: string; className?: string }) {
|
||||
return (
|
||||
<svg
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox="0 0 24 24"
|
||||
fill={fill}
|
||||
stroke="currentColor"
|
||||
strokeWidth={2}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
className={className}
|
||||
>
|
||||
<polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
export function FileEditor({
|
||||
content,
|
||||
fileName,
|
||||
filePath,
|
||||
fileSize,
|
||||
fileModified,
|
||||
error,
|
||||
isStarred,
|
||||
onStar,
|
||||
onSave,
|
||||
}: FileEditorProps) {
|
||||
const [isEditing, setIsEditing] = useState(false)
|
||||
const [editContent, setEditContent] = useState(content)
|
||||
const [isSaving, setIsSaving] = useState(false)
|
||||
const [saveStatus, setSaveStatus] = useState<'idle' | 'saving' | 'saved' | 'error'>('idle')
|
||||
const [lastSavedContent, setLastSavedContent] = useState(content)
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null)
|
||||
|
||||
const isMarkdown = fileName.toLowerCase().endsWith('.md') || fileName.toLowerCase().endsWith('.markdown')
|
||||
|
||||
// Track previous file path to detect file changes
|
||||
const prevFilePathRef = useRef<string | undefined>(filePath)
|
||||
|
||||
// Reset edit state when file changes to prevent saving stale content
|
||||
useEffect(() => {
|
||||
if (filePath !== prevFilePathRef.current) {
|
||||
// File changed - exit edit mode and reset buffer
|
||||
setIsEditing(false)
|
||||
setEditContent(content)
|
||||
setLastSavedContent(content)
|
||||
setSaveStatus('idle')
|
||||
prevFilePathRef.current = filePath
|
||||
} else if (!isEditing && content !== lastSavedContent) {
|
||||
// Sync content when not editing and it changed externally
|
||||
setEditContent(content)
|
||||
setLastSavedContent(content)
|
||||
}
|
||||
}, [content, isEditing, lastSavedContent, filePath])
|
||||
|
||||
// Track if content has unsaved changes
|
||||
const hasUnsavedChanges = editContent !== lastSavedContent
|
||||
|
||||
const handleSave = useCallback(() => {
|
||||
if (!onSave || !hasUnsavedChanges) return
|
||||
setIsSaving(true)
|
||||
setSaveStatus('saving')
|
||||
onSave(editContent, (success) => {
|
||||
setIsSaving(false)
|
||||
if (success) {
|
||||
setLastSavedContent(editContent)
|
||||
setSaveStatus('saved')
|
||||
setTimeout(() => setSaveStatus('idle'), 2000)
|
||||
} else {
|
||||
setSaveStatus('error')
|
||||
setTimeout(() => setSaveStatus('idle'), 2000)
|
||||
}
|
||||
})
|
||||
}, [editContent, hasUnsavedChanges, onSave])
|
||||
|
||||
const handleCancel = useCallback(() => {
|
||||
setEditContent(lastSavedContent)
|
||||
setIsEditing(false)
|
||||
setSaveStatus('idle')
|
||||
}, [lastSavedContent])
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
// Ctrl/Cmd + S to save
|
||||
if ((e.ctrlKey || e.metaKey) && e.key === 's') {
|
||||
e.preventDefault()
|
||||
handleSave()
|
||||
}
|
||||
// Escape to cancel
|
||||
if (e.key === 'Escape') {
|
||||
handleCancel()
|
||||
}
|
||||
}
|
||||
|
||||
// Focus textarea when entering edit mode
|
||||
useEffect(() => {
|
||||
if (isEditing && textareaRef.current) {
|
||||
textareaRef.current.focus()
|
||||
}
|
||||
}, [isEditing])
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="h-full flex flex-col items-center justify-center p-8 text-center">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.9 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
className="flex flex-col items-center gap-4"
|
||||
>
|
||||
<div className="w-16 h-16 rounded-full bg-crab-900/30 flex items-center justify-center border border-crab-700/50">
|
||||
<AlertCircle size={32} className="text-crab-400" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-display text-lg text-crab-400 mb-2">Error Loading File</h3>
|
||||
<p className="font-console text-sm text-shell-500 max-w-md">{error}</p>
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!content && !fileName) {
|
||||
return (
|
||||
<div className="h-full flex flex-col items-center justify-center p-8 text-center">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
className="flex flex-col items-center gap-4"
|
||||
>
|
||||
<div className="w-16 h-16 rounded-full bg-shell-800/50 flex items-center justify-center border border-shell-700">
|
||||
<FileText size={32} className="text-shell-500" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-display text-lg text-gray-400 mb-2">No File Selected</h3>
|
||||
<p className="font-console text-sm text-shell-500 max-w-md">
|
||||
Select a file from the sidebar to view its contents
|
||||
</p>
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="h-full flex flex-col">
|
||||
{/* File header */}
|
||||
<div className="flex items-center gap-2 px-4 py-3 border-b border-shell-800 bg-shell-900/50 min-w-0">
|
||||
{/* Left: Star button */}
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
{filePath && onStar && (
|
||||
<button
|
||||
onClick={() => onStar(filePath)}
|
||||
className={`p-1.5 rounded transition-colors ${isStarred
|
||||
? 'text-yellow-400 hover:text-yellow-300'
|
||||
: 'text-shell-600 hover:text-yellow-400'
|
||||
}`}
|
||||
title={isStarred ? 'Unstar file' : 'Star file'}
|
||||
>
|
||||
<Star size={16} fill={isStarred ? 'currentColor' : 'none'} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Middle: Filename and indicators - flexes and truncates */}
|
||||
<div className="flex items-center gap-2 flex-1 min-w-0 overflow-hidden">
|
||||
<FileText size={18} className={`shrink-0 ${isMarkdown ? 'text-crab-400' : 'text-shell-500'}`} />
|
||||
<h2 className="font-display text-sm text-gray-200 truncate min-w-0">{fileName}</h2>
|
||||
|
||||
{/* Unsaved changes indicator */}
|
||||
{isEditing && hasUnsavedChanges && (
|
||||
<span className="hidden sm:inline px-2 py-0.5 bg-neon-peach/10 text-neon-peach text-[11px] font-console uppercase rounded border border-neon-peach/30 animate-pulse shrink-0">
|
||||
Unsaved
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* Save status */}
|
||||
<AnimatePresence>
|
||||
{isEditing && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.8 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
exit={{ opacity: 0, scale: 0.8 }}
|
||||
className="hidden sm:flex items-center gap-1.5 shrink-0"
|
||||
>
|
||||
{saveStatus === 'saving' && (
|
||||
<>
|
||||
<div className="w-2.5 h-2.5 border-2 border-neon-cyan border-t-transparent rounded-full animate-spin" />
|
||||
<span className="font-console text-[11px] text-neon-cyan">Saving...</span>
|
||||
</>
|
||||
)}
|
||||
{saveStatus === 'saved' && (
|
||||
<>
|
||||
<Check size={12} className="text-neon-mint" />
|
||||
<span className="font-console text-[11px] text-neon-mint">Saved</span>
|
||||
</>
|
||||
)}
|
||||
{saveStatus === 'error' && (
|
||||
<>
|
||||
<AlertCircle size={12} className="text-neon-peach" />
|
||||
<span className="font-console text-[11px] text-neon-peach">Save failed</span>
|
||||
</>
|
||||
)}
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
|
||||
{/* Right: Metadata + Action buttons */}
|
||||
<div className="flex items-center gap-3 shrink-0 overflow-hidden">
|
||||
<div className="hidden min-[480px]:flex items-center gap-3">
|
||||
{fileSize !== undefined && (
|
||||
<span className="font-console text-[11px] text-shell-500 whitespace-nowrap">
|
||||
{formatFileSize(fileSize)}
|
||||
</span>
|
||||
)}
|
||||
{fileModified && (
|
||||
<span className="font-console text-[11px] text-shell-500 whitespace-nowrap">
|
||||
{formatModifiedDate(fileModified)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Action buttons */}
|
||||
{onSave && (
|
||||
<div className="flex items-center gap-2">
|
||||
{!isEditing ? (
|
||||
<button
|
||||
onClick={() => setIsEditing(true)}
|
||||
className="flex items-center gap-1.5 px-2.5 py-1.5 bg-shell-800 hover:bg-shell-700 rounded-lg text-sm font-console text-gray-300 transition-colors border border-shell-700"
|
||||
title="Edit file (or press E)"
|
||||
>
|
||||
<Edit2 size={14} />
|
||||
<span className="hidden lg:inline">Edit</span>
|
||||
</button>
|
||||
) : (
|
||||
<>
|
||||
<button
|
||||
onClick={handleCancel}
|
||||
className="flex items-center gap-1.5 px-2.5 py-1.5 bg-shell-800 hover:bg-shell-700 rounded-lg text-sm font-console text-gray-300 transition-colors border border-shell-700"
|
||||
title="Cancel (Esc)"
|
||||
>
|
||||
<X size={14} />
|
||||
<span className="hidden sm:inline">Cancel</span>
|
||||
</button>
|
||||
{hasUnsavedChanges && (
|
||||
<button
|
||||
onClick={handleSave}
|
||||
disabled={isSaving}
|
||||
className="flex items-center gap-1.5 px-2.5 py-1.5 bg-neon-mint/10 hover:bg-neon-mint/20 rounded-lg text-sm font-console text-neon-mint transition-colors border border-neon-mint/30 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
title="Save (Ctrl+S)"
|
||||
>
|
||||
<Save size={14} />
|
||||
<span className="hidden sm:inline">Save</span>
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 overflow-auto p-6">
|
||||
<div className="max-w-[1200px] mx-auto h-full">
|
||||
<AnimatePresence mode="wait">
|
||||
{isEditing ? (
|
||||
<motion.div
|
||||
key="editor"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
className="h-full"
|
||||
>
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
value={editContent}
|
||||
onChange={(e) => setEditContent(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
className="w-full h-full min-h-[400px] bg-shell-900 border border-shell-700 rounded-lg p-4 font-mono text-sm font-light text-gray-300 placeholder-shell-600 resize-none focus:outline-none focus:border-crab-500 focus:ring-1 focus:ring-crab-500/20 wrap-break-words"
|
||||
spellCheck={false}
|
||||
autoCapitalize="off"
|
||||
autoCorrect="off"
|
||||
autoComplete="off"
|
||||
/>
|
||||
</motion.div>
|
||||
) : (
|
||||
<motion.div
|
||||
key="viewer"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{ duration: 0.15 }}
|
||||
className="min-w-0"
|
||||
>
|
||||
{isMarkdown ? (
|
||||
<div className="prose prose-invert prose-sm max-w-full wrap-break-words">
|
||||
<ReactMarkdown
|
||||
components={{
|
||||
h1: ({ children }) => (
|
||||
<h1 className="text-2xl font-display text-crab-400 mb-4 pb-2 border-b border-shell-800">
|
||||
{children}
|
||||
</h1>
|
||||
),
|
||||
h2: ({ children }) => (
|
||||
<h2 className="text-xl font-display text-neon-mint mt-6 mb-3">{children}</h2>
|
||||
),
|
||||
h3: ({ children }) => (
|
||||
<h3 className="text-lg font-display text-gray-200 mt-4 mb-2">{children}</h3>
|
||||
),
|
||||
p: ({ children }) => (
|
||||
<p className="text-gray-300 leading-relaxed mb-4">{children}</p>
|
||||
),
|
||||
code: ({ children, className }) => {
|
||||
const isInline = !className
|
||||
return isInline ? (
|
||||
<code className="bg-shell-800 text-neon-peach px-1.5 py-0.5 rounded text-sm font-mono">
|
||||
{children}
|
||||
</code>
|
||||
) : (
|
||||
<pre className="bg-shell-900 border border-shell-800 rounded-lg p-4 mb-4 max-w-full">
|
||||
<code className="text-xs sm:text-sm font-mono text-gray-300 whitespace-pre-wrap break-all">{children}</code>
|
||||
</pre>
|
||||
)
|
||||
},
|
||||
ul: ({ children }) => (
|
||||
<ul className="list-disc list-inside text-gray-300 mb-4 space-y-1">{children}</ul>
|
||||
),
|
||||
ol: ({ children }) => (
|
||||
<ol className="list-decimal list-inside text-gray-300 mb-4 space-y-1">{children}</ol>
|
||||
),
|
||||
li: ({ children }) => <li className="text-gray-300">{children}</li>,
|
||||
a: ({ children, href }) => (
|
||||
<a
|
||||
href={href}
|
||||
className="text-neon-cyan hover:text-neon-mint transition-colors underline"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
{children}
|
||||
</a>
|
||||
),
|
||||
blockquote: ({ children }) => (
|
||||
<blockquote className="border-l-4 border-crab-500 pl-4 italic text-shell-400 mb-4">
|
||||
{children}
|
||||
</blockquote>
|
||||
),
|
||||
hr: () => <hr className="border-shell-700 my-6" />,
|
||||
table: ({ children }) => (
|
||||
<table className="w-full border-collapse mb-4">{children}</table>
|
||||
),
|
||||
thead: ({ children }) => (
|
||||
<thead className="bg-shell-800">{children}</thead>
|
||||
),
|
||||
th: ({ children }) => (
|
||||
<th className="border border-shell-700 px-4 py-2 text-left font-display text-sm text-gray-200">
|
||||
{children}
|
||||
</th>
|
||||
),
|
||||
td: ({ children }) => (
|
||||
<td className="border border-shell-700 px-4 py-2 text-sm text-gray-300">
|
||||
{children}
|
||||
</td>
|
||||
),
|
||||
}}
|
||||
>
|
||||
{content}
|
||||
</ReactMarkdown>
|
||||
</div>
|
||||
) : (
|
||||
<pre className="font-mono text-xs sm:text-sm text-gray-300 whitespace-pre-wrap break-all overflow-x-auto max-w-full">{content}</pre>
|
||||
)}
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default FileEditor
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useState, useCallback, useEffect } from 'react'
|
||||
import { ChevronRight, ChevronDown, Folder, FolderOpen, FileText } from 'lucide-react'
|
||||
import { ChevronRight, ChevronDown, Folder, FolderOpen, FileText, Plus } from 'lucide-react'
|
||||
import { motion, AnimatePresence } from 'framer-motion'
|
||||
import type { DirectoryEntry } from '~/lib/workspace-fs'
|
||||
|
||||
@@ -18,6 +18,8 @@ interface FileTreeProps {
|
||||
selectedPath: string | null
|
||||
onSelect: (path: string, type: 'file' | 'directory') => void
|
||||
onLoadDirectory?: (path: string) => Promise<DirectoryEntry[]>
|
||||
onContextMenu?: (e: React.MouseEvent, path: string) => void
|
||||
onCreateFile?: (folderPath: string) => void
|
||||
level?: number
|
||||
}
|
||||
|
||||
@@ -26,10 +28,12 @@ interface FileTreeItemProps {
|
||||
selectedPath: string | null
|
||||
onSelect: (path: string, type: 'file' | 'directory') => void
|
||||
onLoadDirectory?: (path: string) => Promise<DirectoryEntry[]>
|
||||
onContextMenu?: (e: React.MouseEvent, path: string) => void
|
||||
onCreateFile?: (folderPath: string) => void
|
||||
level: number
|
||||
}
|
||||
|
||||
function FileTreeItem({ entry, selectedPath, onSelect, onLoadDirectory, level }: FileTreeItemProps) {
|
||||
function FileTreeItem({ entry, selectedPath, onSelect, onLoadDirectory, onContextMenu, onCreateFile, level }: FileTreeItemProps) {
|
||||
const [expanded, setExpanded] = useState(false)
|
||||
const [children, setChildren] = useState<DirectoryEntry[]>([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
@@ -85,12 +89,22 @@ function FileTreeItem({ entry, selectedPath, onSelect, onLoadDirectory, level }:
|
||||
}
|
||||
}, [entry.path, entry.type, expanded, loadChildren, onSelect, isDirectory])
|
||||
|
||||
const handleContextMenuFn = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
if (onContextMenu && entry.type === 'file') {
|
||||
onContextMenu(e, entry.path)
|
||||
}
|
||||
},
|
||||
[entry.path, entry.type, onContextMenu]
|
||||
)
|
||||
|
||||
return (
|
||||
<div>
|
||||
<motion.div
|
||||
onClick={handleClick}
|
||||
onContextMenu={handleContextMenuFn}
|
||||
style={{ paddingLeft }}
|
||||
className={`flex items-center gap-2 py-1.5 pr-2 text-left transition-all duration-150 rounded-md mr-1 cursor-pointer ${
|
||||
className={`group flex items-center gap-2 py-1.5 pr-2 text-left transition-all duration-150 rounded-md mr-1 cursor-pointer ${
|
||||
isSelected
|
||||
? 'bg-crab-500/20 text-crab-400 border-l-2 border-crab-400'
|
||||
: 'text-gray-300 hover:bg-shell-800 hover:text-gray-100 border-l-2 border-transparent'
|
||||
@@ -124,14 +138,14 @@ function FileTreeItem({ entry, selectedPath, onSelect, onLoadDirectory, level }:
|
||||
{/* Icon */}
|
||||
{isDirectory ? (
|
||||
expanded ? (
|
||||
<FolderOpen size={16} className="text-neon-mint flex-shrink-0" />
|
||||
<FolderOpen size={16} className="text-neon-mint shrink-0" />
|
||||
) : (
|
||||
<Folder size={16} className="text-neon-mint flex-shrink-0" />
|
||||
<Folder size={16} className="text-neon-mint shrink-0" />
|
||||
)
|
||||
) : (
|
||||
<FileText
|
||||
size={16}
|
||||
className={`flex-shrink-0 ${
|
||||
className={`shrink-0 ${
|
||||
entry.extension === '.md' ? 'text-crab-400' : 'text-shell-500'
|
||||
}`}
|
||||
/>
|
||||
@@ -148,10 +162,24 @@ function FileTreeItem({ entry, selectedPath, onSelect, onLoadDirectory, level }:
|
||||
|
||||
{/* Metadata for files */}
|
||||
{!isDirectory && (
|
||||
<span className="font-console text-[10px] text-shell-600 flex-shrink-0">
|
||||
<span className={`font-console text-[11px] shrink-0 ${isSelected ? 'text-crab-400/70' : 'text-shell-600'}`}>
|
||||
{entry.size !== undefined && formatFileSize(entry.size)}
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* Add file button for directories */}
|
||||
{isDirectory && onCreateFile && (
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
onCreateFile(entry.path)
|
||||
}}
|
||||
className="p-1 opacity-0 group-hover:opacity-100 hover:bg-shell-700 rounded transition-all shrink-0"
|
||||
title="New file in this folder"
|
||||
>
|
||||
<Plus size={14} className="text-shell-500 hover:text-neon-mint" />
|
||||
</button>
|
||||
)}
|
||||
</motion.div>
|
||||
|
||||
{/* Children */}
|
||||
@@ -172,6 +200,8 @@ function FileTreeItem({ entry, selectedPath, onSelect, onLoadDirectory, level }:
|
||||
selectedPath={selectedPath}
|
||||
onSelect={onSelect}
|
||||
onLoadDirectory={onLoadDirectory}
|
||||
onContextMenu={onContextMenu}
|
||||
onCreateFile={onCreateFile}
|
||||
level={level + 1}
|
||||
/>
|
||||
))
|
||||
@@ -187,7 +217,7 @@ function FileTreeItem({ entry, selectedPath, onSelect, onLoadDirectory, level }:
|
||||
)
|
||||
}
|
||||
|
||||
export function FileTree({ entries, selectedPath, onSelect, onLoadDirectory, level = 0 }: FileTreeProps) {
|
||||
export function FileTree({ entries, selectedPath, onSelect, onLoadDirectory, onContextMenu, onCreateFile, level = 0 }: FileTreeProps) {
|
||||
return (
|
||||
<div className="py-1">
|
||||
{entries.map((entry) => (
|
||||
@@ -197,6 +227,8 @@ export function FileTree({ entries, selectedPath, onSelect, onLoadDirectory, lev
|
||||
selectedPath={selectedPath}
|
||||
onSelect={onSelect}
|
||||
onLoadDirectory={onLoadDirectory}
|
||||
onContextMenu={onContextMenu}
|
||||
onCreateFile={onCreateFile}
|
||||
level={level}
|
||||
/>
|
||||
))}
|
||||
|
||||
@@ -1,222 +0,0 @@
|
||||
import { useMemo } from 'react'
|
||||
import ReactMarkdown from 'react-markdown'
|
||||
import { FileText, AlertCircle, Star } from 'lucide-react'
|
||||
import { motion } from 'framer-motion'
|
||||
|
||||
interface MarkdownViewerProps {
|
||||
content: string
|
||||
fileName: string
|
||||
filePath?: string
|
||||
fileSize?: number
|
||||
fileModified?: Date
|
||||
error?: string
|
||||
isStarred?: boolean
|
||||
onStar?: (path: string) => void
|
||||
}
|
||||
|
||||
// Format file size to human-readable format
|
||||
function formatFileSize(bytes: number | undefined): string {
|
||||
if (bytes === undefined) return ''
|
||||
if (bytes === 0) return '0 B'
|
||||
const k = 1024
|
||||
const sizes = ['B', 'KB', 'MB', 'GB']
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k))
|
||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i]
|
||||
}
|
||||
|
||||
// Format date to relative time
|
||||
function formatModifiedDate(date: Date | undefined): string {
|
||||
if (!date) return ''
|
||||
const d = new Date(date)
|
||||
const now = new Date()
|
||||
const diff = now.getTime() - d.getTime()
|
||||
const seconds = Math.floor(diff / 1000)
|
||||
const minutes = Math.floor(seconds / 60)
|
||||
const hours = Math.floor(minutes / 60)
|
||||
const days = Math.floor(hours / 24)
|
||||
|
||||
if (days > 30) {
|
||||
return d.toLocaleDateString()
|
||||
} else if (days > 0) {
|
||||
return `${days}d ago`
|
||||
} else if (hours > 0) {
|
||||
return `${hours}h ago`
|
||||
} else if (minutes > 0) {
|
||||
return `${minutes}m ago`
|
||||
} else {
|
||||
return 'just now'
|
||||
}
|
||||
}
|
||||
|
||||
export function MarkdownViewer({ content, fileName, filePath, fileSize, fileModified, error, isStarred, onStar }: MarkdownViewerProps) {
|
||||
const isMarkdown = useMemo(() => {
|
||||
return fileName.toLowerCase().endsWith('.md') || fileName.toLowerCase().endsWith('.markdown')
|
||||
}, [fileName])
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="h-full flex flex-col items-center justify-center p-8 text-center">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.9 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
className="flex flex-col items-center gap-4"
|
||||
>
|
||||
<div className="w-16 h-16 rounded-full bg-crab-900/30 flex items-center justify-center border border-crab-700/50">
|
||||
<AlertCircle size={32} className="text-crab-400" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-display text-lg text-crab-400 mb-2">Error Loading File</h3>
|
||||
<p className="font-console text-sm text-shell-500 max-w-md">{error}</p>
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!content && !fileName) {
|
||||
return (
|
||||
<div className="h-full flex flex-col items-center justify-center p-8 text-center">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
className="flex flex-col items-center gap-4"
|
||||
>
|
||||
<div className="w-16 h-16 rounded-full bg-shell-800/50 flex items-center justify-center border border-shell-700">
|
||||
<FileText size={32} className="text-shell-500" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-display text-lg text-gray-400 mb-2">No File Selected</h3>
|
||||
<p className="font-console text-sm text-shell-500 max-w-md">
|
||||
Select a file from the sidebar to view its contents
|
||||
</p>
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="h-full flex flex-col">
|
||||
{/* File header */}
|
||||
<div className="flex items-center gap-3 px-6 py-4 border-b border-shell-800 bg-shell-900/50">
|
||||
{/* Star button */}
|
||||
{filePath && onStar && (
|
||||
<button
|
||||
onClick={() => onStar(filePath)}
|
||||
className={`p-1 rounded transition-colors ${
|
||||
isStarred
|
||||
? 'text-yellow-400 hover:text-yellow-300'
|
||||
: 'text-shell-600 hover:text-yellow-400'
|
||||
}`}
|
||||
title={isStarred ? 'Unstar file' : 'Star file'}
|
||||
>
|
||||
<Star size={16} fill={isStarred ? 'currentColor' : 'none'} />
|
||||
</button>
|
||||
)}
|
||||
<FileText size={18} className={isMarkdown ? 'text-crab-400' : 'text-shell-500'} />
|
||||
<h2 className="font-display text-sm text-gray-200">{fileName}</h2>
|
||||
{isMarkdown && (
|
||||
<span className="px-2 py-0.5 bg-crab-900/30 text-crab-400 text-[10px] font-console uppercase rounded border border-crab-700/30">
|
||||
Markdown
|
||||
</span>
|
||||
)}
|
||||
{/* File metadata */}
|
||||
<div className="flex items-center gap-3 ml-auto">
|
||||
{fileSize !== undefined && (
|
||||
<span className="font-console text-[10px] text-shell-500">
|
||||
{formatFileSize(fileSize)}
|
||||
</span>
|
||||
)}
|
||||
{fileModified && (
|
||||
<span className="font-console text-[10px] text-shell-500">
|
||||
{formatModifiedDate(fileModified)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 overflow-auto p-6">
|
||||
{isMarkdown ? (
|
||||
<div className="prose prose-invert prose-sm max-w-none">
|
||||
<ReactMarkdown
|
||||
components={{
|
||||
h1: ({ children }) => (
|
||||
<h1 className="text-2xl font-display text-crab-400 mb-4 pb-2 border-b border-shell-800">
|
||||
{children}
|
||||
</h1>
|
||||
),
|
||||
h2: ({ children }) => (
|
||||
<h2 className="text-xl font-display text-neon-mint mt-6 mb-3">{children}</h2>
|
||||
),
|
||||
h3: ({ children }) => (
|
||||
<h3 className="text-lg font-display text-gray-200 mt-4 mb-2">{children}</h3>
|
||||
),
|
||||
p: ({ children }) => (
|
||||
<p className="text-gray-300 leading-relaxed mb-4">{children}</p>
|
||||
),
|
||||
code: ({ children, className }) => {
|
||||
const isInline = !className
|
||||
return isInline ? (
|
||||
<code className="bg-shell-800 text-neon-peach px-1.5 py-0.5 rounded text-sm font-mono">
|
||||
{children}
|
||||
</code>
|
||||
) : (
|
||||
<pre className="bg-shell-900 border border-shell-800 rounded-lg p-4 overflow-x-auto mb-4">
|
||||
<code className="text-sm font-mono text-gray-300">{children}</code>
|
||||
</pre>
|
||||
)
|
||||
},
|
||||
ul: ({ children }) => (
|
||||
<ul className="list-disc list-inside text-gray-300 mb-4 space-y-1">{children}</ul>
|
||||
),
|
||||
ol: ({ children }) => (
|
||||
<ol className="list-decimal list-inside text-gray-300 mb-4 space-y-1">{children}</ol>
|
||||
),
|
||||
li: ({ children }) => <li className="text-gray-300">{children}</li>,
|
||||
a: ({ children, href }) => (
|
||||
<a
|
||||
href={href}
|
||||
className="text-neon-cyan hover:text-neon-mint transition-colors underline"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
{children}
|
||||
</a>
|
||||
),
|
||||
blockquote: ({ children }) => (
|
||||
<blockquote className="border-l-4 border-crab-500 pl-4 italic text-shell-400 mb-4">
|
||||
{children}
|
||||
</blockquote>
|
||||
),
|
||||
hr: () => <hr className="border-shell-700 my-6" />,
|
||||
table: ({ children }) => (
|
||||
<table className="w-full border-collapse mb-4">{children}</table>
|
||||
),
|
||||
thead: ({ children }) => (
|
||||
<thead className="bg-shell-800">{children}</thead>
|
||||
),
|
||||
th: ({ children }) => (
|
||||
<th className="border border-shell-700 px-4 py-2 text-left font-display text-sm text-gray-200">
|
||||
{children}
|
||||
</th>
|
||||
),
|
||||
td: ({ children }) => (
|
||||
<td className="border border-shell-700 px-4 py-2 text-sm text-gray-300">
|
||||
{children}
|
||||
</td>
|
||||
),
|
||||
}}
|
||||
>
|
||||
{content}
|
||||
</ReactMarkdown>
|
||||
</div>
|
||||
) : (
|
||||
<pre className="font-mono text-sm text-gray-300 whitespace-pre-wrap">{content}</pre>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default MarkdownViewer
|
||||
@@ -30,7 +30,7 @@ export function MobileBottomToolbar({
|
||||
{/* Files button */}
|
||||
<button
|
||||
onClick={onOpenDrawer}
|
||||
className="p-3 min-w-[48px] min-h-[48px] rounded-lg active:bg-shell-800 text-gray-400 active:text-crab-400 transition-colors"
|
||||
className="shrink-0 p-3 min-w-[48px] min-h-[48px] rounded-lg active:bg-shell-800 text-gray-400 active:text-crab-400 transition-colors"
|
||||
>
|
||||
<PanelLeft size={22} />
|
||||
</button>
|
||||
@@ -38,7 +38,7 @@ export function MobileBottomToolbar({
|
||||
{/* Path input field */}
|
||||
<button
|
||||
onClick={onOpenPathSheet}
|
||||
className="flex-1 flex items-center gap-2 px-3 py-2.5 bg-shell-800 border border-shell-700 rounded-lg active:border-crab-500 transition-colors min-h-[44px]"
|
||||
className="flex-1 min-w-0 flex items-center gap-2 px-3 py-2.5 bg-shell-800 border border-shell-700 rounded-lg active:border-crab-500 transition-colors min-h-[44px] overflow-hidden"
|
||||
>
|
||||
<FolderOpen size={16} className={pathValid ? 'text-crab-400 shrink-0' : 'text-shell-500 shrink-0'} />
|
||||
<span className={`font-console text-sm truncate text-left ${currentPath ? 'text-gray-200' : 'text-shell-500'}`}>
|
||||
@@ -50,7 +50,7 @@ export function MobileBottomToolbar({
|
||||
<button
|
||||
onClick={onRefresh}
|
||||
disabled={!pathValid || loading}
|
||||
className="p-3 min-w-[48px] min-h-[48px] rounded-lg active:bg-shell-800 text-gray-400 active:text-crab-400 transition-colors disabled:opacity-40 disabled:cursor-not-allowed"
|
||||
className="shrink-0 p-3 min-w-[48px] min-h-[48px] rounded-lg active:bg-shell-800 text-gray-400 active:text-crab-400 transition-colors disabled:opacity-40 disabled:cursor-not-allowed"
|
||||
>
|
||||
<RefreshCw size={22} className={loading ? 'animate-spin' : ''} />
|
||||
</button>
|
||||
|
||||
@@ -133,7 +133,7 @@ export function MobileFileDrawer({
|
||||
{/* Footer with path */}
|
||||
{pathValid && (
|
||||
<div className="px-4 py-3 border-t border-shell-800 bg-shell-950/50">
|
||||
<p className="font-console text-[10px] text-shell-600 truncate">
|
||||
<p className="font-console text-[11px] text-shell-600 truncate">
|
||||
{workspacePath}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,200 @@
|
||||
import { useState, useCallback, useRef, useEffect } from 'react'
|
||||
import { motion, AnimatePresence } from 'framer-motion'
|
||||
import { FileText, X, Plus, AlertCircle } from 'lucide-react'
|
||||
|
||||
interface NewFileDialogProps {
|
||||
open: boolean
|
||||
onClose: () => void
|
||||
onCreate: (fileName: string, content: string) => void
|
||||
folderPath?: string // If set, file is created in this folder
|
||||
}
|
||||
|
||||
export function NewFileDialog({ open, onClose, onCreate, folderPath }: NewFileDialogProps) {
|
||||
const [fileName, setFileName] = useState('')
|
||||
const [content, setContent] = useState('')
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const inputRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
// Focus input when dialog opens
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setFileName('')
|
||||
setContent('')
|
||||
setError(null)
|
||||
const timeoutId = setTimeout(() => inputRef.current?.focus(), 50)
|
||||
return () => clearTimeout(timeoutId)
|
||||
}
|
||||
}, [open])
|
||||
|
||||
const handleSubmit = useCallback(async () => {
|
||||
setError(null)
|
||||
|
||||
if (!fileName.trim()) {
|
||||
setError('Please enter a file name')
|
||||
return
|
||||
}
|
||||
|
||||
// Validate file name - allow forward slash for subdirectory creation
|
||||
const invalidChars = /[<>:"\\|?*\x00-\x1f]/g
|
||||
if (invalidChars.test(fileName)) {
|
||||
setError('File name contains invalid characters')
|
||||
return
|
||||
}
|
||||
|
||||
// Check for path traversal - only block parent directory traversal, not relative paths
|
||||
if (fileName.includes('..') || fileName.startsWith('/')) {
|
||||
setError('File name cannot contain path traversal sequences')
|
||||
return
|
||||
}
|
||||
|
||||
// Create the file - wait for completion before closing
|
||||
try {
|
||||
await onCreate(fileName.trim(), content)
|
||||
onClose()
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to create file')
|
||||
}
|
||||
}, [fileName, content, onCreate, onClose])
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
// Only submit on Enter if focus is on the filename input (not content textarea)
|
||||
if (e.key === 'Enter' && e.target === inputRef.current) {
|
||||
handleSubmit()
|
||||
}
|
||||
if (e.key === 'Escape') {
|
||||
onClose()
|
||||
}
|
||||
}
|
||||
|
||||
const addExtension = (ext: string) => {
|
||||
if (!fileName.includes('.')) {
|
||||
setFileName(fileName + ext)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<AnimatePresence>
|
||||
{open && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
|
||||
{/* Backdrop */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
className="absolute inset-0 bg-black/60 backdrop-blur-sm"
|
||||
onClick={onClose}
|
||||
/>
|
||||
|
||||
{/* Dialog */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.95, y: 10 }}
|
||||
animate={{ opacity: 1, scale: 1, y: 0 }}
|
||||
exit={{ opacity: 0, scale: 0.95, y: 10 }}
|
||||
transition={{ type: 'spring', duration: 0.3 }}
|
||||
className="relative w-full max-w-lg bg-shell-900 rounded-xl border border-shell-700 shadow-2xl overflow-hidden"
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-3 px-5 py-4 border-b border-shell-800 bg-neon-mint/5">
|
||||
<div className="p-2 rounded-lg bg-neon-mint/10 border border-neon-mint/30">
|
||||
<Plus size={20} className="text-neon-mint" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<h3 className="font-display text-lg text-gray-200">Create New File</h3>
|
||||
{folderPath && (
|
||||
<p className="font-console text-xs text-shell-500 truncate">
|
||||
in {folderPath.split('/').pop()}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="px-5 py-4 space-y-4">
|
||||
{/* File name input */}
|
||||
<div>
|
||||
<label className="block font-console text-xs text-shell-500 uppercase tracking-wider mb-2">
|
||||
File Name
|
||||
</label>
|
||||
<div className="relative">
|
||||
<FileText size={16} className="absolute left-3 top-1/2 -translate-y-1/2 text-shell-500" />
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
value={fileName}
|
||||
onChange={(e) => setFileName(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder="example.md"
|
||||
className={`w-full bg-shell-800 border rounded-lg pl-10 pr-3 py-2.5 text-sm font-console text-gray-200 placeholder-shell-500 focus:outline-none focus:ring-1 ${
|
||||
error
|
||||
? 'border-crab-500 focus:border-crab-500 focus:ring-crab-500/20'
|
||||
: 'border-shell-700 focus:border-neon-mint focus:ring-neon-mint/20'
|
||||
}`}
|
||||
/>
|
||||
</div>
|
||||
{error && (
|
||||
<div className="flex items-center gap-1.5 mt-2 text-crab-400">
|
||||
<AlertCircle size={12} />
|
||||
<span className="font-console text-xs">{error}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Quick extension buttons */}
|
||||
<div>
|
||||
<label className="block font-console text-xs text-shell-500 uppercase tracking-wider mb-2">
|
||||
Quick Extensions
|
||||
</label>
|
||||
<div className="flex gap-2">
|
||||
{['.md', '.txt', '.json', '.html'].map((ext) => (
|
||||
<button
|
||||
key={ext}
|
||||
onClick={() => addExtension(ext)}
|
||||
className="px-3 py-1.5 bg-shell-800 hover:bg-shell-700 rounded-lg text-xs font-console text-gray-400 transition-colors border border-shell-700"
|
||||
>
|
||||
{ext}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Optional content */}
|
||||
<div>
|
||||
<label className="block font-console text-xs text-shell-500 uppercase tracking-wider mb-2">
|
||||
Initial Content (optional)
|
||||
</label>
|
||||
<textarea
|
||||
value={content}
|
||||
onChange={(e) => setContent(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder="Enter file content..."
|
||||
className="w-full h-32 bg-shell-800 border border-shell-700 rounded-lg p-3 text-sm font-mono text-gray-300 placeholder-shell-600 resize-none focus:outline-none focus:border-neon-mint focus:ring-1 focus:ring-neon-mint/20"
|
||||
spellCheck={false}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="flex items-center justify-end gap-3 px-5 py-4 border-t border-shell-800 bg-shell-900/50">
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-shell-800 hover:bg-shell-700 rounded-lg text-sm font-console text-gray-300 transition-colors border border-shell-700"
|
||||
>
|
||||
<X size={14} />
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={handleSubmit}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-neon-mint hover:bg-neon-mint/90 rounded-lg text-sm font-console text-shell-950 transition-colors"
|
||||
>
|
||||
<Plus size={14} />
|
||||
Create File
|
||||
</button>
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
)
|
||||
}
|
||||
|
||||
export default NewFileDialog
|
||||
@@ -1,5 +1,8 @@
|
||||
export { FileTree } from './FileTree'
|
||||
export { MarkdownViewer } from './MarkdownViewer'
|
||||
export { FileEditor } from './FileEditor'
|
||||
export { MobileBottomToolbar } from './MobileBottomToolbar'
|
||||
export { MobileFileDrawer } from './MobileFileDrawer'
|
||||
export { MobilePathSheet } from './MobilePathSheet'
|
||||
export { ConfirmationDialog } from './ConfirmationDialog'
|
||||
export { FileContextMenu, FileIcon, TrashIcon, CopyIcon, EditIcon, NewFileIcon } from './FileContextMenu'
|
||||
export { NewFileDialog } from './NewFileDialog'
|
||||
|
||||
@@ -9,15 +9,124 @@ import {
|
||||
type AgentEvent,
|
||||
type SessionInfo,
|
||||
type SessionsListParams,
|
||||
type ConnectChallengePayload,
|
||||
createConnectParams,
|
||||
} from './protocol'
|
||||
import {
|
||||
buildSignedDevice,
|
||||
clearStoredDeviceToken,
|
||||
getOrCreateIdentity,
|
||||
loadStoredDeviceToken,
|
||||
saveStoredDeviceToken,
|
||||
} from './device'
|
||||
|
||||
interface ChallengePayload {
|
||||
nonce: string
|
||||
ts: number
|
||||
}
|
||||
const DEFAULT_GATEWAY_URL = process.env.CLAWDBOT_URL || 'ws://127.0.0.1:18789'
|
||||
const DEFAULT_SCOPES = ['operator.read'] as const
|
||||
|
||||
type EventCallback = (event: EventFrame) => void
|
||||
export type GatewayAuthState =
|
||||
| 'unknown'
|
||||
| 'authorized'
|
||||
| 'unpaired'
|
||||
| 'unauthorized'
|
||||
| 'degraded'
|
||||
|
||||
interface PairingInfo {
|
||||
requestId?: string
|
||||
message?: string
|
||||
}
|
||||
|
||||
function normalized(value: unknown): string | undefined {
|
||||
return typeof value === 'string' ? value.trim() || undefined : undefined
|
||||
}
|
||||
|
||||
function isTrustedLoopback(url: string): boolean {
|
||||
try {
|
||||
const u = new URL(url)
|
||||
return u.hostname === '127.0.0.1' || u.hostname === 'localhost' || u.hostname === '::1'
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/** Inline of openclaw selectGatewayConnectAuth / buildGatewayConnectAuth (operator subset). */
|
||||
function selectConnectAuth(params: {
|
||||
envToken?: string
|
||||
storedToken?: string
|
||||
storedScopes?: string[]
|
||||
pendingDeviceTokenRetry?: boolean
|
||||
trustedDeviceTokenRetry?: boolean
|
||||
}) {
|
||||
const authToken = normalized(params.envToken)
|
||||
const storedToken = normalized(params.storedToken)
|
||||
const useRetryToken =
|
||||
params.pendingDeviceTokenRetry === true &&
|
||||
Boolean(authToken && storedToken && params.trustedDeviceTokenRetry)
|
||||
// Reference: resolved when retry OR (!(authToken) && stored)
|
||||
const resolvedDeviceToken =
|
||||
useRetryToken || (!authToken && storedToken) ? storedToken : undefined
|
||||
const usingStoredDeviceToken =
|
||||
Boolean(resolvedDeviceToken && storedToken) && resolvedDeviceToken === storedToken
|
||||
const selectedToken = authToken ?? resolvedDeviceToken
|
||||
return {
|
||||
authToken: selectedToken,
|
||||
// buildGatewayConnectAuth: deviceToken = authDeviceToken ?? resolvedDeviceToken
|
||||
// select sets authDeviceToken only on retry; resolved covers stored-as-primary
|
||||
authDeviceToken: (useRetryToken ? storedToken : undefined) ?? resolvedDeviceToken,
|
||||
signatureToken: selectedToken ?? null,
|
||||
usingStoredDeviceToken,
|
||||
/** Stored token is auth.token (no env) — for clear-and-retry path. */
|
||||
usingStoredAsPrimary: Boolean(!authToken && selectedToken && selectedToken === storedToken),
|
||||
storedScopes: params.storedScopes,
|
||||
}
|
||||
}
|
||||
|
||||
function shouldRetryWithDeviceToken(params: {
|
||||
retryBudgetUsed: boolean
|
||||
currentDeviceToken?: string
|
||||
explicitToken?: string
|
||||
storedToken?: string
|
||||
trustedEndpoint: boolean
|
||||
error?: { code?: string; message?: string; details?: unknown }
|
||||
}): boolean {
|
||||
if (
|
||||
params.retryBudgetUsed ||
|
||||
params.currentDeviceToken ||
|
||||
!params.explicitToken ||
|
||||
!params.storedToken ||
|
||||
!params.trustedEndpoint
|
||||
) {
|
||||
return false
|
||||
}
|
||||
const code = params.error?.code ?? ''
|
||||
const message = (params.error?.message ?? '').toLowerCase()
|
||||
const details = JSON.stringify(params.error?.details ?? '')
|
||||
return (
|
||||
code === 'AUTH_TOKEN_MISMATCH' ||
|
||||
message.includes('auth_token_mismatch') ||
|
||||
message.includes('retry_with_device_token') ||
|
||||
details.includes('retry_with_device_token') ||
|
||||
details.includes('AUTH_TOKEN_MISMATCH')
|
||||
)
|
||||
}
|
||||
|
||||
function shouldRetryClearStoredToken(params: {
|
||||
retryBudgetUsed: boolean
|
||||
usingStoredAsPrimary: boolean
|
||||
envToken?: string
|
||||
error?: { code?: string; message?: string; details?: unknown }
|
||||
}): boolean {
|
||||
if (params.retryBudgetUsed || !params.usingStoredAsPrimary || !params.envToken) return false
|
||||
const code = params.error?.code ?? ''
|
||||
const message = (params.error?.message ?? '').toLowerCase()
|
||||
const details = JSON.stringify(params.error?.details ?? '')
|
||||
return (
|
||||
code === 'AUTH_TOKEN_MISMATCH' ||
|
||||
message.includes('auth_token_mismatch') ||
|
||||
message.includes('retry_with_device_token') ||
|
||||
details.includes('AUTH_TOKEN_MISMATCH')
|
||||
)
|
||||
}
|
||||
|
||||
export class ClawdbotClient {
|
||||
private ws: WebSocket | null = null
|
||||
@@ -30,9 +139,24 @@ export class ClawdbotClient {
|
||||
private reconnectTimer: ReturnType<typeof setTimeout> | null = null
|
||||
private _connected = false
|
||||
private _connecting = false
|
||||
private _authState: GatewayAuthState = 'unknown'
|
||||
private _scopes: string[] = []
|
||||
private _pairingInfo: PairingInfo | null = null
|
||||
private _connectPromiseSettled = false
|
||||
private readonly debugEnabled = process.env.CRABWALK_DEBUG_OPENCLAW === '1'
|
||||
/** One-shot AUTH_TOKEN_MISMATCH retry within a connect attempt. */
|
||||
private _authRetryUsed = false
|
||||
private _pendingDeviceTokenRetry = false
|
||||
private _lastConnectAuth: {
|
||||
authDeviceToken?: string
|
||||
usingStoredAsPrimary: boolean
|
||||
} | null = null
|
||||
private _connectResolve?: (v: HelloOk) => void
|
||||
private _connectReject?: (e: Error) => void
|
||||
private _connectTimeout?: ReturnType<typeof setTimeout>
|
||||
|
||||
constructor(
|
||||
private url: string = 'ws://127.0.0.1:18789',
|
||||
private url: string = DEFAULT_GATEWAY_URL,
|
||||
private token?: string
|
||||
) {}
|
||||
|
||||
@@ -40,17 +164,39 @@ export class ClawdbotClient {
|
||||
return this._connected
|
||||
}
|
||||
|
||||
get authState() {
|
||||
return this._authState
|
||||
}
|
||||
|
||||
get scopes() {
|
||||
return [...this._scopes]
|
||||
}
|
||||
|
||||
get pairingInfo() {
|
||||
return this._pairingInfo
|
||||
}
|
||||
|
||||
async connect(): Promise<HelloOk> {
|
||||
if (this._connecting || this._connected) {
|
||||
return { type: 'hello-ok', protocol: 3 } as HelloOk
|
||||
return { type: 'hello-ok', protocol: 4 } as HelloOk
|
||||
}
|
||||
this._connecting = true
|
||||
this._connectPromiseSettled = false
|
||||
this._authRetryUsed = false
|
||||
this._pendingDeviceTokenRetry = false
|
||||
this._lastConnectAuth = null
|
||||
return new Promise((resolve, reject) => {
|
||||
this._connectResolve = resolve
|
||||
this._connectReject = reject
|
||||
const timeout = setTimeout(() => {
|
||||
this._connecting = false
|
||||
this.ws?.close()
|
||||
reject(new Error('Connection timeout - is openclaw gateway running?'))
|
||||
if (!this._connectPromiseSettled) {
|
||||
this._connectPromiseSettled = true
|
||||
reject(new Error('Connection timeout - is openclaw gateway running?'))
|
||||
}
|
||||
}, 10000)
|
||||
this._connectTimeout = timeout
|
||||
|
||||
try {
|
||||
this.ws = new WebSocket(this.url)
|
||||
@@ -61,7 +207,7 @@ export class ClawdbotClient {
|
||||
}
|
||||
|
||||
this.ws.once('open', () => {
|
||||
// WebSocket connected, waiting for challenge
|
||||
this.debugLog('socket open, waiting for connect.challenge')
|
||||
})
|
||||
|
||||
this.ws.on('message', (data) => {
|
||||
@@ -71,11 +217,11 @@ export class ClawdbotClient {
|
||||
|
||||
// Handle challenge-response auth
|
||||
if (msg.type === 'event' && msg.event === 'connect.challenge') {
|
||||
this.handleChallenge(msg.payload as ChallengePayload)
|
||||
this.handleChallenge(msg.payload as ConnectChallengePayload)
|
||||
return
|
||||
}
|
||||
|
||||
this.handleMessage(msg, resolve, reject, timeout)
|
||||
this.handleMessage(msg)
|
||||
} catch (e) {
|
||||
console.error('[openclaw] Failed to parse message:', e)
|
||||
}
|
||||
@@ -84,14 +230,33 @@ export class ClawdbotClient {
|
||||
this.ws.on('error', (err) => {
|
||||
clearTimeout(timeout)
|
||||
this._connecting = false
|
||||
reject(err)
|
||||
this.debugLog('socket error before connect', err)
|
||||
if (!this._connectPromiseSettled) {
|
||||
this._connectPromiseSettled = true
|
||||
reject(err)
|
||||
}
|
||||
})
|
||||
|
||||
this.ws.on('close', (code, _reason) => {
|
||||
this.ws.on('close', (code, reason) => {
|
||||
clearTimeout(timeout)
|
||||
const wasConnected = this._connected
|
||||
const wasConnecting = this._connecting
|
||||
this.debugLog('socket close', {
|
||||
code,
|
||||
reason: reason?.toString?.() ?? '',
|
||||
wasConnected,
|
||||
wasConnecting,
|
||||
})
|
||||
this._connected = false
|
||||
this._connecting = false
|
||||
if (!wasConnected && wasConnecting && !this._connectPromiseSettled) {
|
||||
this._connectPromiseSettled = true
|
||||
reject(
|
||||
new Error(
|
||||
`Gateway closed before connect (code ${code}${reason ? `, reason: ${reason.toString()}` : ''})`
|
||||
)
|
||||
)
|
||||
}
|
||||
// Only reconnect if we were previously connected and it wasn't a clean close
|
||||
if (wasConnected && code !== 1000) {
|
||||
this.scheduleReconnect()
|
||||
@@ -100,12 +265,69 @@ export class ClawdbotClient {
|
||||
})
|
||||
}
|
||||
|
||||
private handleChallenge(_challenge: ChallengePayload) {
|
||||
if (!this.token || this.ws?.readyState !== WebSocket.OPEN) {
|
||||
private handleChallenge(challenge: ConnectChallengePayload) {
|
||||
if (this.ws?.readyState !== WebSocket.OPEN) {
|
||||
return
|
||||
}
|
||||
|
||||
const params = createConnectParams(this.token)
|
||||
const stored = loadStoredDeviceToken()
|
||||
const selected = selectConnectAuth({
|
||||
envToken: this.token,
|
||||
storedToken: stored?.token,
|
||||
storedScopes: stored?.scopes,
|
||||
pendingDeviceTokenRetry: this._pendingDeviceTokenRetry,
|
||||
trustedDeviceTokenRetry: isTrustedLoopback(this.url),
|
||||
})
|
||||
|
||||
const scopes =
|
||||
selected.usingStoredDeviceToken && stored?.scopes?.length
|
||||
? stored.scopes
|
||||
: [...DEFAULT_SCOPES]
|
||||
|
||||
let params = createConnectParams({
|
||||
token: selected.authToken,
|
||||
deviceToken: selected.authDeviceToken,
|
||||
scopes,
|
||||
})
|
||||
|
||||
// Payload platform must match client.platform after normalize (createConnectParams sets raw platform)
|
||||
try {
|
||||
const device = buildSignedDevice({
|
||||
challenge,
|
||||
token: selected.signatureToken,
|
||||
role: params.role,
|
||||
scopes: params.scopes,
|
||||
clientId: params.client.id,
|
||||
clientMode: params.client.mode,
|
||||
platform: params.client.platform,
|
||||
})
|
||||
params = createConnectParams({
|
||||
token: selected.authToken,
|
||||
deviceToken: selected.authDeviceToken,
|
||||
scopes,
|
||||
device,
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('[openclaw] Failed to create signed device identity:', error)
|
||||
}
|
||||
|
||||
this._lastConnectAuth = {
|
||||
authDeviceToken: selected.authDeviceToken,
|
||||
usingStoredAsPrimary: selected.usingStoredAsPrimary,
|
||||
}
|
||||
|
||||
this.debugLog('sending connect', {
|
||||
hasToken: Boolean(params.auth?.token),
|
||||
hasDeviceToken: Boolean(params.auth?.deviceToken),
|
||||
hasDevice: Boolean(params.device),
|
||||
deviceId: params.device?.id,
|
||||
usingStored: selected.usingStoredDeviceToken,
|
||||
pendingRetry: this._pendingDeviceTokenRetry,
|
||||
clientMode: params.client.mode,
|
||||
clientPlatform: params.client.platform,
|
||||
scopes: params.scopes,
|
||||
})
|
||||
|
||||
const response: RequestFrame = {
|
||||
type: 'req',
|
||||
id: `connect-${Date.now()}`,
|
||||
@@ -116,27 +338,149 @@ export class ClawdbotClient {
|
||||
this.ws.send(JSON.stringify(response))
|
||||
}
|
||||
|
||||
private handleMessage(
|
||||
msg: GatewayFrame | HelloOk,
|
||||
connectResolve?: (v: HelloOk) => void,
|
||||
_connectReject?: (e: Error) => void,
|
||||
connectTimeout?: ReturnType<typeof setTimeout>
|
||||
) {
|
||||
private handleConnectFailure(error?: { code?: string; message?: string; details?: unknown }) {
|
||||
const stored = loadStoredDeviceToken()
|
||||
const trusted = isTrustedLoopback(this.url)
|
||||
const last = this._lastConnectAuth
|
||||
|
||||
// Env primary failed → retry once with cached device token
|
||||
if (
|
||||
shouldRetryWithDeviceToken({
|
||||
retryBudgetUsed: this._authRetryUsed,
|
||||
currentDeviceToken: last?.authDeviceToken,
|
||||
explicitToken: this.token,
|
||||
storedToken: stored?.token,
|
||||
trustedEndpoint: trusted,
|
||||
error,
|
||||
})
|
||||
) {
|
||||
this._authRetryUsed = true
|
||||
this._pendingDeviceTokenRetry = true
|
||||
this.debugLog('AUTH_TOKEN_MISMATCH — retrying with device token')
|
||||
this.reopenForAuthRetry()
|
||||
return
|
||||
}
|
||||
|
||||
// Stored primary failed → clear token file, retry once with env token only
|
||||
if (
|
||||
shouldRetryClearStoredToken({
|
||||
retryBudgetUsed: this._authRetryUsed,
|
||||
usingStoredAsPrimary: Boolean(last?.usingStoredAsPrimary),
|
||||
envToken: this.token,
|
||||
error,
|
||||
})
|
||||
) {
|
||||
this._authRetryUsed = true
|
||||
this._pendingDeviceTokenRetry = false
|
||||
clearStoredDeviceToken()
|
||||
this.debugLog('AUTH_TOKEN_MISMATCH — cleared stored device token, retrying with env')
|
||||
this.reopenForAuthRetry()
|
||||
return
|
||||
}
|
||||
|
||||
const message = error?.message || 'Connect failed'
|
||||
this.updateAuthStateFromError(message)
|
||||
if (this._connectTimeout) clearTimeout(this._connectTimeout)
|
||||
this._connecting = false
|
||||
if (!this._connectPromiseSettled) {
|
||||
this._connectPromiseSettled = true
|
||||
this._connectReject?.(new Error(message))
|
||||
}
|
||||
}
|
||||
|
||||
/** Re-open socket for one-shot auth retry without settling the outer connect promise. */
|
||||
private reopenForAuthRetry() {
|
||||
// Detach old socket so its close handler cannot reject the connect promise.
|
||||
const old = this.ws
|
||||
this.ws = null
|
||||
if (old) {
|
||||
old.removeAllListeners()
|
||||
old.on('error', () => {})
|
||||
try {
|
||||
old.close()
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
this._connected = false
|
||||
try {
|
||||
this.ws = new WebSocket(this.url)
|
||||
} catch (e) {
|
||||
this._connecting = false
|
||||
if (!this._connectPromiseSettled) {
|
||||
this._connectPromiseSettled = true
|
||||
this._connectReject?.(new Error(`Failed to create WebSocket for auth retry: ${e}`))
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
this.ws.once('open', () => {
|
||||
this.debugLog('auth-retry socket open, waiting for connect.challenge')
|
||||
})
|
||||
|
||||
this.ws.on('message', (data) => {
|
||||
try {
|
||||
const msg = JSON.parse(data.toString())
|
||||
if (msg.type === 'event' && msg.event === 'connect.challenge') {
|
||||
this.handleChallenge(msg.payload as ConnectChallengePayload)
|
||||
return
|
||||
}
|
||||
this.handleMessage(msg)
|
||||
} catch (e) {
|
||||
console.error('[openclaw] Failed to parse message:', e)
|
||||
}
|
||||
})
|
||||
|
||||
this.ws.on('error', (err) => {
|
||||
this.debugLog('auth-retry socket error', err)
|
||||
if (!this._connectPromiseSettled) {
|
||||
this._connectPromiseSettled = true
|
||||
this._connecting = false
|
||||
this._connectReject?.(err instanceof Error ? err : new Error(String(err)))
|
||||
}
|
||||
})
|
||||
|
||||
this.ws.on('close', (code, reason) => {
|
||||
this.debugLog('auth-retry socket close', {
|
||||
code,
|
||||
reason: reason?.toString?.() ?? '',
|
||||
})
|
||||
if (this._connecting && !this._connectPromiseSettled && !this._connected) {
|
||||
this._connectPromiseSettled = true
|
||||
this._connecting = false
|
||||
this._connectReject?.(
|
||||
new Error(
|
||||
`Gateway closed during auth retry (code ${code}${reason ? `, reason: ${reason.toString()}` : ''})`
|
||||
)
|
||||
)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
private handleMessage(msg: GatewayFrame | HelloOk) {
|
||||
if ('type' in msg) {
|
||||
switch (msg.type) {
|
||||
case 'hello-ok':
|
||||
if (connectTimeout) clearTimeout(connectTimeout)
|
||||
if (this._connectTimeout) clearTimeout(this._connectTimeout)
|
||||
this.updateAuthStateFromHello(msg)
|
||||
this._connected = true
|
||||
connectResolve?.(msg)
|
||||
this._connecting = false
|
||||
this._connectPromiseSettled = true
|
||||
this._connectResolve?.(msg)
|
||||
break
|
||||
|
||||
case 'res':
|
||||
// Check if this is the hello-ok response to our connect request
|
||||
if (msg.ok && (msg.payload as HelloOk)?.type === 'hello-ok') {
|
||||
if (connectTimeout) clearTimeout(connectTimeout)
|
||||
if (this._connectTimeout) clearTimeout(this._connectTimeout)
|
||||
this.updateAuthStateFromHello(msg.payload as HelloOk)
|
||||
this._connected = true
|
||||
this._connecting = false
|
||||
connectResolve?.(msg.payload as HelloOk)
|
||||
this._connectPromiseSettled = true
|
||||
this._connectResolve?.(msg.payload as HelloOk)
|
||||
} else if (!msg.ok && String(msg.id).startsWith('connect-')) {
|
||||
this.handleConnectFailure(msg.error)
|
||||
} else {
|
||||
this.handleResponse(msg)
|
||||
}
|
||||
@@ -160,12 +504,25 @@ export class ClawdbotClient {
|
||||
if (res.ok) {
|
||||
pending.resolve(res.payload)
|
||||
} else {
|
||||
pending.reject(new Error(res.error?.message || 'Request failed'))
|
||||
const message = res.error?.message || 'Request failed'
|
||||
this.updateAuthStateFromError(message)
|
||||
pending.reject(new Error(message))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private handleEvent(event: EventFrame) {
|
||||
if (event.event.includes('pair') || event.event.includes('device')) {
|
||||
const payload = event.payload as { requestId?: string; message?: string } | undefined
|
||||
if (payload?.requestId || payload?.message) {
|
||||
this._authState = 'unpaired'
|
||||
this._pairingInfo = {
|
||||
requestId: payload.requestId,
|
||||
message: payload.message,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const listener of this.eventListeners) {
|
||||
try {
|
||||
listener(event)
|
||||
@@ -233,17 +590,94 @@ export class ClawdbotClient {
|
||||
this.ws = null
|
||||
}
|
||||
this._connected = false
|
||||
this._authState = 'unknown'
|
||||
this._scopes = []
|
||||
this._pairingInfo = null
|
||||
}
|
||||
|
||||
private updateAuthStateFromHello(hello: HelloOk) {
|
||||
const scopes = hello.auth?.scopes
|
||||
this._scopes = scopes ? [...scopes] : []
|
||||
|
||||
if (hello.auth?.deviceToken) {
|
||||
const identity = getOrCreateIdentity()
|
||||
saveStoredDeviceToken({
|
||||
deviceId: identity.id,
|
||||
token: hello.auth.deviceToken,
|
||||
role: hello.auth.role,
|
||||
scopes: hello.auth.scopes,
|
||||
updatedAtMs: Date.now(),
|
||||
})
|
||||
this.debugLog('persisted device token')
|
||||
}
|
||||
|
||||
if (!scopes) {
|
||||
this._authState = 'authorized'
|
||||
return
|
||||
}
|
||||
|
||||
if (scopes.includes('operator.read')) {
|
||||
this._authState = 'authorized'
|
||||
this._pairingInfo = null
|
||||
this.debugLog('authorized scopes', scopes)
|
||||
return
|
||||
}
|
||||
|
||||
this._authState = scopes.length === 0 ? 'unpaired' : 'degraded'
|
||||
this.debugLog('non-authorized scopes', scopes)
|
||||
}
|
||||
|
||||
private updateAuthStateFromError(message: string) {
|
||||
const lowered = message.toLowerCase()
|
||||
if (lowered.includes('missing scope') || lowered.includes('operator.read')) {
|
||||
this._authState = 'unpaired'
|
||||
const requestId = this.extractRequestId(message)
|
||||
this._pairingInfo = {
|
||||
requestId: requestId ?? this._pairingInfo?.requestId,
|
||||
message,
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (lowered.includes('unauthorized') || lowered.includes('forbidden')) {
|
||||
this._authState = 'unauthorized'
|
||||
this._pairingInfo = { message }
|
||||
}
|
||||
}
|
||||
|
||||
private debugLog(message: string, payload?: unknown) {
|
||||
if (!this.debugEnabled) return
|
||||
if (payload !== undefined) {
|
||||
console.log(`[openclaw][debug] ${message}`, payload)
|
||||
return
|
||||
}
|
||||
console.log(`[openclaw][debug] ${message}`)
|
||||
}
|
||||
|
||||
private extractRequestId(message: string): string | undefined {
|
||||
const explicitMatch = message.match(/request(?:\s+id)?[:=\s]+([a-zA-Z0-9_-]+)/i)
|
||||
if (explicitMatch?.[1]) {
|
||||
return explicitMatch[1]
|
||||
}
|
||||
|
||||
const uuidMatch = message.match(
|
||||
/\b[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}\b/i
|
||||
)
|
||||
return uuidMatch?.[0]
|
||||
}
|
||||
}
|
||||
|
||||
// Singleton instance for server use
|
||||
let clientInstance: ClawdbotClient | null = null
|
||||
|
||||
export function getClawdbotEndpoint(): string {
|
||||
return DEFAULT_GATEWAY_URL
|
||||
}
|
||||
|
||||
export function getClawdbotClient(): ClawdbotClient {
|
||||
if (!clientInstance) {
|
||||
const url = process.env.CLAWDBOT_URL || 'ws://127.0.0.1:18789'
|
||||
const token = process.env.CLAWDBOT_API_TOKEN
|
||||
clientInstance = new ClawdbotClient(url, token)
|
||||
clientInstance = new ClawdbotClient(DEFAULT_GATEWAY_URL, token)
|
||||
}
|
||||
return clientInstance
|
||||
}
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
/**
|
||||
* Self-check for v3 device-auth payload (no test framework).
|
||||
* Run: node --experimental-strip-types src/integrations/openclaw/device-auth.selfcheck.ts
|
||||
*/
|
||||
import {
|
||||
buildDeviceAuthPayloadV3,
|
||||
normalizeDeviceMetadataForAuth,
|
||||
} from './device.ts'
|
||||
|
||||
function assert(cond: unknown, msg: string): asserts cond {
|
||||
if (!cond) throw new Error(msg)
|
||||
}
|
||||
|
||||
const platform = normalizeDeviceMetadataForAuth('Linux')
|
||||
assert(platform === 'linux', `normalize DeviceMetadata: expected 'linux', got '${platform}'`)
|
||||
|
||||
const payload = buildDeviceAuthPayloadV3({
|
||||
deviceId: 'abc123',
|
||||
clientId: 'cli',
|
||||
clientMode: 'cli',
|
||||
role: 'operator',
|
||||
scopes: ['operator.read'],
|
||||
signedAtMs: 1700000000000,
|
||||
token: 'tok',
|
||||
nonce: 'nonce-1',
|
||||
platform: 'Linux',
|
||||
deviceFamily: '',
|
||||
})
|
||||
|
||||
const expected =
|
||||
'v3|abc123|cli|cli|operator|operator.read|1700000000000|tok|nonce-1|linux|'
|
||||
|
||||
assert(payload === expected, `payload mismatch:\n got: ${payload}\n exp: ${expected}`)
|
||||
|
||||
console.log('device-auth.selfcheck: ok')
|
||||
@@ -0,0 +1,242 @@
|
||||
import fs from 'fs'
|
||||
import path from 'path'
|
||||
import {
|
||||
createHash,
|
||||
createPrivateKey,
|
||||
generateKeyPairSync,
|
||||
sign,
|
||||
} from 'crypto'
|
||||
import type { ConnectChallengePayload, ConnectDevice } from './protocol'
|
||||
|
||||
const DATA_DIR = path.join(process.cwd(), 'data')
|
||||
const DEVICE_IDENTITY_FILE = path.join(DATA_DIR, 'device-identity.json')
|
||||
const DEVICE_TOKEN_FILE = path.join(DATA_DIR, 'device-token.json')
|
||||
|
||||
interface StoredDeviceIdentity {
|
||||
id: string
|
||||
publicKey: string
|
||||
privateKeyPem: string
|
||||
createdAt: number
|
||||
lastUsedAt: number
|
||||
}
|
||||
|
||||
export interface StoredDeviceToken {
|
||||
deviceId: string
|
||||
token: string
|
||||
role?: string
|
||||
scopes?: string[]
|
||||
updatedAtMs: number
|
||||
}
|
||||
|
||||
function base64UrlToBuffer(value: string): Buffer {
|
||||
const padded = value.padEnd(value.length + ((4 - (value.length % 4)) % 4), '=')
|
||||
const base64 = padded.replace(/-/g, '+').replace(/_/g, '/')
|
||||
return Buffer.from(base64, 'base64')
|
||||
}
|
||||
|
||||
function base64UrlEncode(value: Buffer): string {
|
||||
return value.toString('base64').replaceAll('+', '-').replaceAll('/', '_').replace(/=+$/g, '')
|
||||
}
|
||||
|
||||
function decodePublicKey(value: string): Buffer {
|
||||
try {
|
||||
const raw = base64UrlToBuffer(value)
|
||||
if (raw.length > 0) return raw
|
||||
} catch {
|
||||
// fallback below
|
||||
}
|
||||
return Buffer.from(value, 'base64')
|
||||
}
|
||||
|
||||
function fingerprintFromPublicKey(publicKey: string): string {
|
||||
const rawPublicKey = decodePublicKey(publicKey)
|
||||
return createHash('sha256').update(rawPublicKey).digest('hex')
|
||||
}
|
||||
|
||||
function ensureDataDir() {
|
||||
if (!fs.existsSync(DATA_DIR)) {
|
||||
fs.mkdirSync(DATA_DIR, { recursive: true })
|
||||
}
|
||||
}
|
||||
|
||||
function loadStoredIdentity(): StoredDeviceIdentity | null {
|
||||
try {
|
||||
if (!fs.existsSync(DEVICE_IDENTITY_FILE)) {
|
||||
return null
|
||||
}
|
||||
const data = JSON.parse(fs.readFileSync(DEVICE_IDENTITY_FILE, 'utf-8')) as StoredDeviceIdentity
|
||||
if (!data.publicKey || !data.privateKeyPem) {
|
||||
return null
|
||||
}
|
||||
const canonicalId = fingerprintFromPublicKey(data.publicKey)
|
||||
const normalized: StoredDeviceIdentity = {
|
||||
...data,
|
||||
id: canonicalId,
|
||||
}
|
||||
// Auto-migrate legacy id formats to canonical fingerprint.
|
||||
if (data.id !== canonicalId) {
|
||||
saveStoredIdentity(normalized)
|
||||
}
|
||||
return normalized
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function saveStoredIdentity(identity: StoredDeviceIdentity) {
|
||||
ensureDataDir()
|
||||
fs.writeFileSync(DEVICE_IDENTITY_FILE, JSON.stringify(identity, null, 2), { mode: 0o600 })
|
||||
try {
|
||||
fs.chmodSync(DEVICE_IDENTITY_FILE, 0o600)
|
||||
} catch {
|
||||
// ponytail: chmod best-effort on platforms that ignore mode
|
||||
}
|
||||
}
|
||||
|
||||
function generateStoredIdentity(): StoredDeviceIdentity {
|
||||
const { publicKey, privateKey } = generateKeyPairSync('ed25519')
|
||||
const publicJwk = publicKey.export({ format: 'jwk' })
|
||||
if (!publicJwk.x) {
|
||||
throw new Error('Failed to export Ed25519 public key')
|
||||
}
|
||||
|
||||
const rawPublicKey = base64UrlToBuffer(publicJwk.x)
|
||||
const fingerprint = createHash('sha256').update(rawPublicKey).digest('hex')
|
||||
const now = Date.now()
|
||||
|
||||
return {
|
||||
id: fingerprint,
|
||||
publicKey: base64UrlEncode(rawPublicKey),
|
||||
privateKeyPem: privateKey.export({ format: 'pem', type: 'pkcs8' }).toString(),
|
||||
createdAt: now,
|
||||
lastUsedAt: now,
|
||||
}
|
||||
}
|
||||
|
||||
/** Verbatim from openclaw gateway-client device-auth.ts */
|
||||
export function normalizeDeviceMetadataForAuth(value?: string | null): string {
|
||||
if (typeof value !== 'string') {
|
||||
return ''
|
||||
}
|
||||
const trimmed = value.trim()
|
||||
if (!trimmed) {
|
||||
return ''
|
||||
}
|
||||
return trimmed.replace(/[A-Z]/g, (char) => String.fromCharCode(char.charCodeAt(0) + 32))
|
||||
}
|
||||
|
||||
export function buildDeviceAuthPayloadV3(params: {
|
||||
deviceId: string
|
||||
clientId: string
|
||||
clientMode: string
|
||||
role: string
|
||||
scopes: string[]
|
||||
signedAtMs: number
|
||||
token?: string | null
|
||||
nonce: string
|
||||
platform?: string | null
|
||||
deviceFamily?: string | null
|
||||
}): string {
|
||||
const scopes = params.scopes.join(',')
|
||||
const token = params.token ?? ''
|
||||
const platform = normalizeDeviceMetadataForAuth(params.platform)
|
||||
const deviceFamily = normalizeDeviceMetadataForAuth(params.deviceFamily)
|
||||
return [
|
||||
'v3',
|
||||
params.deviceId,
|
||||
params.clientId,
|
||||
params.clientMode,
|
||||
params.role,
|
||||
scopes,
|
||||
String(params.signedAtMs),
|
||||
token,
|
||||
params.nonce,
|
||||
platform,
|
||||
deviceFamily,
|
||||
].join('|')
|
||||
}
|
||||
|
||||
interface BuildSignedDeviceParams {
|
||||
challenge: ConnectChallengePayload
|
||||
token: string | null
|
||||
role: string
|
||||
scopes: string[]
|
||||
clientId: string
|
||||
clientMode: string
|
||||
platform?: string | null
|
||||
deviceFamily?: string | null
|
||||
}
|
||||
|
||||
export function getOrCreateIdentity(): StoredDeviceIdentity {
|
||||
const existing = loadStoredIdentity()
|
||||
if (existing) {
|
||||
return existing
|
||||
}
|
||||
const generated = generateStoredIdentity()
|
||||
saveStoredIdentity(generated)
|
||||
return generated
|
||||
}
|
||||
|
||||
export function buildSignedDevice(params: BuildSignedDeviceParams): ConnectDevice {
|
||||
const nonce = params.challenge.nonce?.trim()
|
||||
if (!nonce) {
|
||||
throw new Error('connect.challenge nonce is empty — refusing to sign (would fall back to v1)')
|
||||
}
|
||||
|
||||
const identity = getOrCreateIdentity()
|
||||
const privateKey = createPrivateKey(identity.privateKeyPem)
|
||||
const signedAt = Date.now()
|
||||
const payload = buildDeviceAuthPayloadV3({
|
||||
deviceId: identity.id,
|
||||
clientId: params.clientId,
|
||||
clientMode: params.clientMode,
|
||||
role: params.role,
|
||||
scopes: params.scopes,
|
||||
signedAtMs: signedAt,
|
||||
token: params.token,
|
||||
nonce,
|
||||
platform: params.platform,
|
||||
deviceFamily: params.deviceFamily ?? '',
|
||||
})
|
||||
const signature = base64UrlEncode(sign(null, Buffer.from(payload, 'utf8'), privateKey))
|
||||
|
||||
identity.lastUsedAt = signedAt
|
||||
saveStoredIdentity(identity)
|
||||
|
||||
return {
|
||||
id: identity.id,
|
||||
publicKey: identity.publicKey,
|
||||
signature,
|
||||
signedAt,
|
||||
nonce,
|
||||
}
|
||||
}
|
||||
|
||||
export function loadStoredDeviceToken(): StoredDeviceToken | null {
|
||||
try {
|
||||
if (!fs.existsSync(DEVICE_TOKEN_FILE)) return null
|
||||
const data = JSON.parse(fs.readFileSync(DEVICE_TOKEN_FILE, 'utf-8')) as StoredDeviceToken
|
||||
if (!data.token || !data.deviceId) return null
|
||||
return data
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export function saveStoredDeviceToken(entry: StoredDeviceToken) {
|
||||
ensureDataDir()
|
||||
fs.writeFileSync(DEVICE_TOKEN_FILE, JSON.stringify(entry, null, 2), { mode: 0o600 })
|
||||
try {
|
||||
fs.chmodSync(DEVICE_TOKEN_FILE, 0o600)
|
||||
} catch {
|
||||
// ponytail: chmod best-effort
|
||||
}
|
||||
}
|
||||
|
||||
export function clearStoredDeviceToken() {
|
||||
try {
|
||||
if (fs.existsSync(DEVICE_TOKEN_FILE)) fs.unlinkSync(DEVICE_TOKEN_FILE)
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
@@ -55,7 +55,13 @@ export function chatEventToAction(event: ChatEvent): MonitorAction {
|
||||
}
|
||||
}
|
||||
|
||||
if (event.message) {
|
||||
// v4: deltaText when replace===true or message absent; else cumulative message path
|
||||
if (
|
||||
typeof event.deltaText === 'string' &&
|
||||
(event.replace === true || event.message == null)
|
||||
) {
|
||||
action.content = event.deltaText
|
||||
} else if (event.message) {
|
||||
if (typeof event.message === 'string') {
|
||||
action.content = event.message
|
||||
} else if (typeof event.message === 'object') {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// Clawdbot Gateway Protocol v3 types
|
||||
// Clawdbot Gateway Protocol v4 types
|
||||
|
||||
// Frame types
|
||||
export interface RequestFrame {
|
||||
@@ -13,7 +13,7 @@ export interface ResponseFrame {
|
||||
id: string
|
||||
ok: boolean
|
||||
payload?: unknown
|
||||
error?: { code: string; message: string }
|
||||
error?: { code: string; message: string; details?: unknown }
|
||||
}
|
||||
|
||||
export interface EventFrame {
|
||||
@@ -32,14 +32,34 @@ export interface ClientInfo {
|
||||
displayName: string
|
||||
version: string
|
||||
platform: string
|
||||
mode: 'ui' | 'cli' | 'bot'
|
||||
mode: 'ui' | 'cli' | 'bot' | 'operator' | 'node'
|
||||
}
|
||||
|
||||
export interface ConnectParams {
|
||||
minProtocol: 3
|
||||
maxProtocol: 3
|
||||
minProtocol: 3 | 4
|
||||
maxProtocol: 3 | 4
|
||||
client: ClientInfo
|
||||
auth?: { token?: string }
|
||||
auth?: { token?: string; deviceToken?: string }
|
||||
device?: ConnectDevice
|
||||
}
|
||||
|
||||
export interface ConnectChallengePayload {
|
||||
nonce: string
|
||||
ts: number
|
||||
}
|
||||
|
||||
export interface ConnectDevice {
|
||||
id: string
|
||||
publicKey: string
|
||||
signature: string
|
||||
signedAt: number
|
||||
nonce: string
|
||||
}
|
||||
|
||||
export interface HelloAuth {
|
||||
role?: string
|
||||
scopes?: string[]
|
||||
deviceToken?: string
|
||||
}
|
||||
|
||||
export interface HelloOk {
|
||||
@@ -51,6 +71,7 @@ export interface HelloOk {
|
||||
stateVersion: { presence: number; health: number }
|
||||
}
|
||||
features: { methods: string[]; events: string[] }
|
||||
auth?: HelloAuth
|
||||
}
|
||||
|
||||
export interface PresenceEntry {
|
||||
@@ -59,14 +80,15 @@ export interface PresenceEntry {
|
||||
connectedAt: number
|
||||
}
|
||||
|
||||
// Chat events
|
||||
// Note: gateway sends cumulative message content with each delta, not incremental chars
|
||||
// Chat events — v4 may send deltaText (incremental) and/or cumulative message
|
||||
export interface ChatEvent {
|
||||
runId: string
|
||||
sessionKey: string
|
||||
seq: number
|
||||
state: 'delta' | 'final' | 'aborted' | 'error'
|
||||
message?: unknown
|
||||
deltaText?: string
|
||||
replace?: boolean
|
||||
errorMessage?: string
|
||||
usage?: {
|
||||
inputTokens?: number
|
||||
@@ -231,24 +253,66 @@ export function parseSessionKey(key: string): {
|
||||
return { agentId, platform, recipient, isGroup }
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
export function createConnectParams(token?: string): any {
|
||||
export type CreateConnectOptions = {
|
||||
token?: string
|
||||
deviceToken?: string
|
||||
device?: ConnectDevice
|
||||
scopes?: string[]
|
||||
}
|
||||
|
||||
export function createConnectParams(
|
||||
tokenOrOpts?: string | CreateConnectOptions,
|
||||
device?: ConnectDevice
|
||||
): ConnectParams & {
|
||||
role: string
|
||||
scopes: string[]
|
||||
caps: unknown[]
|
||||
commands: unknown[]
|
||||
permissions: Record<string, unknown>
|
||||
locale: string
|
||||
userAgent: string
|
||||
} {
|
||||
// ponytail: overload keeps call sites short; prefer opts object for deviceToken/scopes
|
||||
const opts: CreateConnectOptions =
|
||||
typeof tokenOrOpts === 'string' || tokenOrOpts === undefined
|
||||
? { token: tokenOrOpts, device }
|
||||
: tokenOrOpts
|
||||
|
||||
const platformMap: Record<string, string> = {
|
||||
win32: 'windows',
|
||||
darwin: 'macos',
|
||||
linux: 'linux',
|
||||
}
|
||||
const platform = platformMap[process.platform] ?? process.platform
|
||||
|
||||
const authToken = opts.token
|
||||
const authDeviceToken = opts.deviceToken
|
||||
const auth =
|
||||
authToken || authDeviceToken
|
||||
? {
|
||||
...(authToken ? { token: authToken } : {}),
|
||||
...(authDeviceToken ? { deviceToken: authDeviceToken } : {}),
|
||||
}
|
||||
: undefined
|
||||
|
||||
return {
|
||||
minProtocol: 3,
|
||||
maxProtocol: 3,
|
||||
minProtocol: 4,
|
||||
maxProtocol: 4,
|
||||
client: {
|
||||
id: 'cli',
|
||||
displayName: 'crabwalk-monitor',
|
||||
version: '0.1.0',
|
||||
platform: 'linux',
|
||||
platform,
|
||||
mode: 'cli',
|
||||
},
|
||||
role: 'operator',
|
||||
scopes: ['operator.read'],
|
||||
scopes: opts.scopes ?? ['operator.read'],
|
||||
caps: [],
|
||||
commands: [],
|
||||
permissions: {},
|
||||
locale: 'en-US',
|
||||
userAgent: 'crabwalk-monitor/0.1.0',
|
||||
auth: token ? { token } : undefined,
|
||||
auth,
|
||||
device: opts.device,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@ import { initTRPC } from '@trpc/server'
|
||||
import { observable } from '@trpc/server/observable'
|
||||
import superjson from 'superjson'
|
||||
import { z } from 'zod'
|
||||
import { getClawdbotClient } from '~/integrations/openclaw/client'
|
||||
import { getClawdbotClient, getClawdbotEndpoint } from '~/integrations/openclaw/client'
|
||||
import { getPersistenceService } from '~/integrations/openclaw/persistence'
|
||||
import {
|
||||
parseEventFrame,
|
||||
@@ -14,6 +14,9 @@ import {
|
||||
import {
|
||||
listDirectory,
|
||||
readFile,
|
||||
writeFile,
|
||||
deleteFile,
|
||||
createFile,
|
||||
pathExists,
|
||||
getDefaultWorkspacePath,
|
||||
expandTilde,
|
||||
@@ -40,7 +43,12 @@ const openclawRouter = router({
|
||||
connect: publicProcedure.mutation(async () => {
|
||||
const client = getClawdbotClient()
|
||||
if (client.connected) {
|
||||
return { status: 'already_connected' as const }
|
||||
return {
|
||||
status: 'already_connected' as const,
|
||||
authState: client.authState,
|
||||
scopes: client.scopes,
|
||||
pairing: client.pairingInfo,
|
||||
}
|
||||
}
|
||||
try {
|
||||
const hello = await client.connect()
|
||||
@@ -49,11 +57,17 @@ const openclawRouter = router({
|
||||
protocol: hello.protocol,
|
||||
features: hello.features,
|
||||
presenceCount: hello.snapshot?.presence?.length ?? 0,
|
||||
authState: client.authState,
|
||||
scopes: client.scopes,
|
||||
pairing: client.pairingInfo,
|
||||
}
|
||||
} catch (error) {
|
||||
return {
|
||||
status: 'error' as const,
|
||||
message: error instanceof Error ? error.message : 'Connection failed',
|
||||
authState: client.authState,
|
||||
scopes: client.scopes,
|
||||
pairing: client.pairingInfo,
|
||||
}
|
||||
}
|
||||
}),
|
||||
@@ -69,6 +83,20 @@ const openclawRouter = router({
|
||||
return { connected: client.connected }
|
||||
}),
|
||||
|
||||
authStatus: publicProcedure.query(() => {
|
||||
const client = getClawdbotClient()
|
||||
return {
|
||||
connected: client.connected,
|
||||
authState: client.authState,
|
||||
scopes: client.scopes,
|
||||
pairing: client.pairingInfo,
|
||||
}
|
||||
}),
|
||||
|
||||
gatewayEndpoint: publicProcedure.query(() => {
|
||||
return { url: getClawdbotEndpoint() }
|
||||
}),
|
||||
|
||||
setDebugMode: publicProcedure
|
||||
.input(z.object({ enabled: z.boolean() }))
|
||||
.mutation(({ input }) => {
|
||||
@@ -127,7 +155,7 @@ const openclawRouter = router({
|
||||
const client = getClawdbotClient()
|
||||
const persistence = getPersistenceService()
|
||||
if (!client.connected) {
|
||||
return { sessions: [], error: 'Not connected' }
|
||||
return { sessions: [], error: 'Not connected', authState: client.authState }
|
||||
}
|
||||
try {
|
||||
const sessions = await client.listSessions(input)
|
||||
@@ -136,11 +164,14 @@ const openclawRouter = router({
|
||||
for (const session of monitorSessions) {
|
||||
persistence.upsertSession(session)
|
||||
}
|
||||
return { sessions: monitorSessions }
|
||||
return { sessions: monitorSessions, authState: client.authState, scopes: client.scopes }
|
||||
} catch (error) {
|
||||
return {
|
||||
sessions: [],
|
||||
error: error instanceof Error ? error.message : 'Failed to list sessions',
|
||||
authState: client.authState,
|
||||
scopes: client.scopes,
|
||||
pairing: client.pairingInfo,
|
||||
}
|
||||
}
|
||||
}),
|
||||
@@ -286,6 +317,59 @@ const workspaceRouter = router({
|
||||
}
|
||||
}
|
||||
}),
|
||||
|
||||
// Write file contents
|
||||
writeFile: publicProcedure
|
||||
.input(z.object({ workspaceRoot: z.string(), path: z.string(), content: z.string() }))
|
||||
.mutation(async ({ input }): Promise<{ success: boolean; error?: string }> => {
|
||||
try {
|
||||
const expandedRoot = expandTilde(input.workspaceRoot)
|
||||
const expandedPath = expandTilde(input.path)
|
||||
await writeFile(expandedRoot, expandedPath, input.content)
|
||||
return { success: true }
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Failed to write file',
|
||||
}
|
||||
}
|
||||
}),
|
||||
|
||||
// Delete file
|
||||
deleteFile: publicProcedure
|
||||
.input(z.object({ workspaceRoot: z.string(), path: z.string() }))
|
||||
.mutation(async ({ input }): Promise<{ success: boolean; error?: string }> => {
|
||||
try {
|
||||
const expandedRoot = expandTilde(input.workspaceRoot)
|
||||
const expandedPath = expandTilde(input.path)
|
||||
await deleteFile(expandedRoot, expandedPath)
|
||||
return { success: true }
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Failed to delete file',
|
||||
}
|
||||
}
|
||||
}),
|
||||
|
||||
// Create file
|
||||
createFile: publicProcedure
|
||||
.input(z.object({ workspaceRoot: z.string(), fileName: z.string(), content: z.string().optional() }))
|
||||
.mutation(async ({ input }): Promise<{ success: boolean; error?: string; filePath?: string }> => {
|
||||
try {
|
||||
const expandedRoot = expandTilde(input.workspaceRoot)
|
||||
// Construct path server-side using Node.js path.join
|
||||
const path = await import('path')
|
||||
const fullPath = path.join(expandedRoot, input.fileName)
|
||||
await createFile(expandedRoot, fullPath, input.content || '')
|
||||
return { success: true, filePath: fullPath }
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Failed to create file',
|
||||
}
|
||||
}
|
||||
}),
|
||||
})
|
||||
|
||||
export const appRouter = router({
|
||||
|
||||
+115
-8
@@ -24,26 +24,31 @@ export interface FileContent {
|
||||
|
||||
/**
|
||||
* Validates that a path is within the allowed workspace root
|
||||
* Prevents directory traversal attacks
|
||||
* Prevents directory traversal attacks and symlink escapes
|
||||
*/
|
||||
export function validatePath(workspaceRoot: string, targetPath: string): string {
|
||||
export async function validatePath(workspaceRoot: string, targetPath: string): Promise<string> {
|
||||
// Resolve to absolute paths
|
||||
const resolvedRoot = path.resolve(workspaceRoot)
|
||||
const resolvedTarget = path.resolve(targetPath)
|
||||
|
||||
// Resolve symlinks to prevent escaping workspace via symlinked paths
|
||||
// This ensures we validate the actual filesystem location, not the symlink itself
|
||||
const realRoot = await fs.realpath(resolvedRoot)
|
||||
const realTarget = await fs.realpath(resolvedTarget)
|
||||
|
||||
// Normalize paths for cross-platform comparison
|
||||
// Convert backslashes to forward slashes and ensure consistent formatting
|
||||
const normalizeForComparison = (p: string) => p.replace(/\\/g, '/').replace(/\/$/, '')
|
||||
const normalizedRoot = normalizeForComparison(resolvedRoot) + '/'
|
||||
const normalizedTarget = normalizeForComparison(resolvedTarget)
|
||||
const normalizedRoot = normalizeForComparison(realRoot) + '/'
|
||||
const normalizedTarget = normalizeForComparison(realTarget)
|
||||
|
||||
// Ensure target path is within root path by checking with trailing separator
|
||||
// This prevents bypasses like /home/user/workspace-evil matching /home/user/workspace
|
||||
if (!normalizedTarget.startsWith(normalizedRoot) && normalizedTarget !== normalizeForComparison(resolvedRoot)) {
|
||||
if (!normalizedTarget.startsWith(normalizedRoot) && normalizedTarget !== normalizeForComparison(realRoot)) {
|
||||
throw new Error('Path traversal detected: target path is outside workspace root')
|
||||
}
|
||||
|
||||
return resolvedTarget
|
||||
return realTarget
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -54,7 +59,7 @@ export async function listDirectory(
|
||||
workspaceRoot: string,
|
||||
targetPath: string
|
||||
): Promise<DirectoryEntry[]> {
|
||||
const safePath = validatePath(workspaceRoot, targetPath)
|
||||
const safePath = await validatePath(workspaceRoot, targetPath)
|
||||
|
||||
try {
|
||||
const entries = await fs.readdir(safePath, { withFileTypes: true })
|
||||
@@ -111,7 +116,7 @@ export async function readFile(
|
||||
workspaceRoot: string,
|
||||
filePath: string
|
||||
): Promise<FileContent> {
|
||||
const safePath = validatePath(workspaceRoot, filePath)
|
||||
const safePath = await validatePath(workspaceRoot, filePath)
|
||||
|
||||
try {
|
||||
// Check if file exists and is a file
|
||||
@@ -250,3 +255,105 @@ export function isTextFile(filename: string): boolean {
|
||||
const ext = lastDotIndex > 0 ? path.extname(filename).toLowerCase() : ''
|
||||
return textExtensions.includes(ext) || ext === ''
|
||||
}
|
||||
|
||||
/**
|
||||
* Writes content to a file
|
||||
* Creates the file if it doesn't exist, overwrites if it does
|
||||
*/
|
||||
export async function writeFile(
|
||||
workspaceRoot: string,
|
||||
filePath: string,
|
||||
content: string
|
||||
): Promise<void> {
|
||||
const safePath = await validatePath(workspaceRoot, filePath)
|
||||
|
||||
try {
|
||||
// Check if parent directory exists
|
||||
const parentDir = path.dirname(safePath)
|
||||
const parentStats = await fs.stat(parentDir)
|
||||
if (!parentStats.isDirectory()) {
|
||||
throw new Error('Parent path is not a directory')
|
||||
}
|
||||
|
||||
// Write file content
|
||||
await fs.writeFile(safePath, content, 'utf-8')
|
||||
} catch (error) {
|
||||
throw new Error(
|
||||
`Failed to write file: ${error instanceof Error ? error.message : 'Unknown error'}`
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes a file
|
||||
*/
|
||||
export async function deleteFile(
|
||||
workspaceRoot: string,
|
||||
filePath: string
|
||||
): Promise<void> {
|
||||
const safePath = await validatePath(workspaceRoot, filePath)
|
||||
|
||||
try {
|
||||
// Check if file exists and is a file
|
||||
const stats = await fs.stat(safePath)
|
||||
if (!stats.isFile()) {
|
||||
throw new Error('Path is not a file')
|
||||
}
|
||||
|
||||
// Delete the file
|
||||
await fs.unlink(safePath)
|
||||
} catch (error) {
|
||||
throw new Error(
|
||||
`Failed to delete file: ${error instanceof Error ? error.message : 'Unknown error'}`
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a new file with optional content
|
||||
*/
|
||||
export async function createFile(
|
||||
workspaceRoot: string,
|
||||
filePath: string,
|
||||
content: string = ''
|
||||
): Promise<void> {
|
||||
// Expand user home directory and resolve path
|
||||
const expandedRoot = expandTilde(workspaceRoot)
|
||||
const expandedFilePath = expandTilde(filePath)
|
||||
|
||||
// Get the parent directory path
|
||||
const parentDir = path.dirname(expandedFilePath)
|
||||
const fileName = path.basename(expandedFilePath)
|
||||
|
||||
// Validate that parent directory is within workspace root
|
||||
const safeParentPath = await validatePath(expandedRoot, parentDir)
|
||||
|
||||
// Check if file already exists
|
||||
const exists = await pathExists(expandedFilePath)
|
||||
if (exists) {
|
||||
throw new Error('File already exists')
|
||||
}
|
||||
|
||||
// Check if parent directory exists
|
||||
try {
|
||||
const parentStats = await fs.stat(safeParentPath)
|
||||
if (!parentStats.isDirectory()) {
|
||||
throw new Error('Parent path is not a directory')
|
||||
}
|
||||
} catch (error) {
|
||||
// Preserve original error if it's already a descriptive error we threw
|
||||
if (error instanceof Error && error.message === 'Parent path is not a directory') {
|
||||
throw error
|
||||
}
|
||||
throw new Error('Parent directory does not exist')
|
||||
}
|
||||
|
||||
// Create the file
|
||||
try {
|
||||
await fs.writeFile(expandedFilePath, content, 'utf-8')
|
||||
} catch (error) {
|
||||
throw new Error(
|
||||
`Failed to create file: ${error instanceof Error ? error.message : 'Unknown error'}`
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -64,15 +64,26 @@ function MonitorPageWrapper() {
|
||||
|
||||
const RETRY_DELAY = 3000
|
||||
const MAX_RETRIES = 10
|
||||
const DEFAULT_GATEWAY_ENDPOINT = 'ws://127.0.0.1:18789'
|
||||
type AuthState = 'unknown' | 'authorized' | 'unpaired' | 'unauthorized' | 'degraded'
|
||||
|
||||
interface PairingState {
|
||||
requestId?: string
|
||||
message?: string
|
||||
}
|
||||
|
||||
function MonitorPage() {
|
||||
const [connected, setConnected] = useState(false)
|
||||
const [connecting, setConnecting] = useState(false)
|
||||
const [authState, setAuthState] = useState<AuthState>('unknown')
|
||||
const [scopes, setScopes] = useState<string[]>([])
|
||||
const [pairing, setPairing] = useState<PairingState | null>(null)
|
||||
const [retryCount, setRetryCount] = useState(0)
|
||||
const [historicalMode, setHistoricalMode] = useState(false)
|
||||
const [debugMode, setDebugMode] = useState(false)
|
||||
const [logCollection, setLogCollection] = useState(false)
|
||||
const [logCount, setLogCount] = useState(0)
|
||||
const [gatewayEndpoint, setGatewayEndpoint] = useState(DEFAULT_GATEWAY_ENDPOINT)
|
||||
const [selectedSession, setSelectedSession] = useState<string | null>(null)
|
||||
|
||||
// Persistence service state
|
||||
@@ -115,9 +126,20 @@ function MonitorPage() {
|
||||
// Check connection status and persistence on mount
|
||||
useEffect(() => {
|
||||
checkStatus()
|
||||
checkAuthStatus()
|
||||
checkPersistenceStatus()
|
||||
loadGatewayEndpoint()
|
||||
}, [])
|
||||
|
||||
const loadGatewayEndpoint = async () => {
|
||||
try {
|
||||
const data = await trpc.openclaw.gatewayEndpoint.query()
|
||||
setGatewayEndpoint(data.url)
|
||||
} catch {
|
||||
// keep default
|
||||
}
|
||||
}
|
||||
|
||||
const checkPersistenceStatus = async () => {
|
||||
try {
|
||||
const status = await trpc.openclaw.persistenceStatus.query()
|
||||
@@ -139,18 +161,43 @@ function MonitorPage() {
|
||||
}
|
||||
}
|
||||
|
||||
const checkAuthStatus = useCallback(async () => {
|
||||
try {
|
||||
const status = await trpc.openclaw.authStatus.query()
|
||||
setConnected(status.connected)
|
||||
setAuthState(status.authState as AuthState)
|
||||
setScopes(status.scopes ?? [])
|
||||
setPairing(status.pairing ?? null)
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}, [])
|
||||
|
||||
const canPollSessions = useMemo(() => {
|
||||
if (!connected) return false
|
||||
if (authState === 'unpaired' || authState === 'unauthorized' || authState === 'degraded') {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}, [connected, authState])
|
||||
|
||||
const handleConnect = async (retry = 0) => {
|
||||
setConnecting(true)
|
||||
setRetryCount(retry)
|
||||
try {
|
||||
const result = await trpc.openclaw.connect.mutate()
|
||||
setAuthState((result.authState as AuthState) ?? 'unknown')
|
||||
setScopes(result.scopes ?? [])
|
||||
setPairing(result.pairing ?? null)
|
||||
if (result.status === 'connected' || result.status === 'already_connected') {
|
||||
setConnected(true)
|
||||
setRetryCount(0)
|
||||
setConnecting(false)
|
||||
// Hydrate from persistence if enabled
|
||||
await hydrateFromPersistence()
|
||||
await loadSessions()
|
||||
if (result.authState === 'authorized' || result.authState === 'unknown') {
|
||||
// Hydrate from persistence if enabled
|
||||
await hydrateFromPersistence()
|
||||
await loadSessions()
|
||||
}
|
||||
return
|
||||
}
|
||||
} catch {
|
||||
@@ -187,17 +234,24 @@ function MonitorPage() {
|
||||
try {
|
||||
await trpc.openclaw.disconnect.mutate()
|
||||
setConnected(false)
|
||||
setAuthState('unknown')
|
||||
setScopes([])
|
||||
setPairing(null)
|
||||
clearCollections()
|
||||
} catch (e) {
|
||||
console.error('Disconnect error:', e)
|
||||
}
|
||||
}
|
||||
|
||||
const loadSessions = async () => {
|
||||
const loadSessions = useCallback(async () => {
|
||||
try {
|
||||
const result = await trpc.openclaw.sessions.query(
|
||||
historicalMode ? { activeMinutes: 1440 } : { activeMinutes: 60 }
|
||||
)
|
||||
setAuthState((prev) => (result.authState as AuthState) ?? prev)
|
||||
setScopes((prev) => result.scopes ?? prev)
|
||||
setPairing((prev) => result.pairing ?? prev)
|
||||
|
||||
if (result.sessions) {
|
||||
for (const session of result.sessions) {
|
||||
upsertSession(session)
|
||||
@@ -206,15 +260,18 @@ function MonitorPage() {
|
||||
} catch (e) {
|
||||
console.error('Failed to load sessions:', e)
|
||||
}
|
||||
}
|
||||
}, [historicalMode])
|
||||
|
||||
const handleRefresh = useCallback(async () => {
|
||||
await loadSessions()
|
||||
}, [historicalMode])
|
||||
await checkAuthStatus()
|
||||
if (canPollSessions) {
|
||||
await loadSessions()
|
||||
}
|
||||
}, [checkAuthStatus, canPollSessions, loadSessions])
|
||||
|
||||
const handleHistoricalModeChange = (enabled: boolean) => {
|
||||
setHistoricalMode(enabled)
|
||||
if (connected) {
|
||||
if (canPollSessions) {
|
||||
loadSessions()
|
||||
}
|
||||
}
|
||||
@@ -325,6 +382,15 @@ function MonitorPage() {
|
||||
return () => clearInterval(interval)
|
||||
}, [])
|
||||
|
||||
// Poll lightweight auth status while connected
|
||||
useEffect(() => {
|
||||
if (!connected) return
|
||||
const interval = setInterval(() => {
|
||||
checkAuthStatus()
|
||||
}, 10000)
|
||||
return () => clearInterval(interval)
|
||||
}, [connected, checkAuthStatus])
|
||||
|
||||
const handleToggleSidebar = useCallback(() => {
|
||||
setSidebarCollapsed((prev) => !prev)
|
||||
}, [])
|
||||
@@ -338,16 +404,16 @@ function MonitorPage() {
|
||||
|
||||
// Poll for sessions while connected
|
||||
useEffect(() => {
|
||||
if (!connected) return
|
||||
if (!canPollSessions) return
|
||||
const interval = setInterval(() => {
|
||||
loadSessions()
|
||||
}, 5000) // Poll every 5 seconds
|
||||
return () => clearInterval(interval)
|
||||
}, [connected, historicalMode])
|
||||
}, [canPollSessions, loadSessions])
|
||||
|
||||
// Subscribe to real-time events
|
||||
useEffect(() => {
|
||||
if (!connected) return
|
||||
if (!canPollSessions) return
|
||||
|
||||
const subscription = trpc.openclaw.events.subscribe(undefined, {
|
||||
onData: (data) => {
|
||||
@@ -369,7 +435,11 @@ function MonitorPage() {
|
||||
return () => {
|
||||
subscription.unsubscribe()
|
||||
}
|
||||
}, [connected])
|
||||
}, [canPollSessions])
|
||||
|
||||
const pairingHint = pairing?.requestId
|
||||
? `openclaw devices approve ${pairing.requestId}`
|
||||
: 'openclaw devices list && openclaw devices approve <requestId>'
|
||||
|
||||
return (
|
||||
<div className="h-screen flex flex-col bg-shell-950 text-white overflow-hidden">
|
||||
@@ -448,12 +518,12 @@ function MonitorPage() {
|
||||
{/* Stats display */}
|
||||
<div className="hidden sm:flex items-center gap-3 px-3 py-1.5 bg-shell-800/50 rounded-lg">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-console text-[10px] text-shell-500 uppercase">Sessions</span>
|
||||
<span className="font-console text-[11px] text-shell-500 uppercase">Sessions</span>
|
||||
<span className="font-display text-sm text-neon-mint">{sessions.length}</span>
|
||||
</div>
|
||||
<div className="w-px h-4 bg-shell-700" />
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-console text-[10px] text-shell-500 uppercase">Actions</span>
|
||||
<span className="font-console text-[11px] text-shell-500 uppercase">Actions</span>
|
||||
<span className="font-display text-sm text-neon-peach">{actions.length}</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -468,6 +538,7 @@ function MonitorPage() {
|
||||
persistenceStartedAt={persistenceStartedAt}
|
||||
persistenceSessionCount={persistenceSessionCount}
|
||||
persistenceActionCount={persistenceActionCount}
|
||||
gatewayEndpoint={gatewayEndpoint}
|
||||
open={settingsOpen}
|
||||
onOpenChange={setSettingsOpen}
|
||||
onHistoricalModeChange={handleHistoricalModeChange}
|
||||
@@ -485,6 +556,17 @@ function MonitorPage() {
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{connected && !canPollSessions && (
|
||||
<div className="px-4 py-2 border-y border-neon-peach/30 bg-neon-peach/10">
|
||||
<div className="font-console text-xs text-neon-peach">
|
||||
Authentication pending. Session polling is paused to avoid missing-scope errors.
|
||||
</div>
|
||||
<div className="font-console text-[11px] text-shell-300 mt-1">
|
||||
{pairing?.message ?? `Approve this device in OpenClaw: ${pairingHint}`}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Main content */}
|
||||
<div className="flex-1 flex overflow-hidden">
|
||||
{/* Sidebar - desktop only */}
|
||||
|
||||
+242
-22
@@ -1,4 +1,4 @@
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { useState, useEffect, useCallback, useMemo } from 'react'
|
||||
import { createFileRoute, Link } from '@tanstack/react-router'
|
||||
import { motion } from 'framer-motion'
|
||||
import {
|
||||
@@ -10,14 +10,21 @@ import {
|
||||
PanelLeftClose,
|
||||
Star,
|
||||
FileText,
|
||||
Plus,
|
||||
} from 'lucide-react'
|
||||
import { trpc } from '~/integrations/trpc/client'
|
||||
import {
|
||||
FileTree,
|
||||
MarkdownViewer,
|
||||
FileEditor,
|
||||
MobileBottomToolbar,
|
||||
MobileFileDrawer,
|
||||
MobilePathSheet,
|
||||
ConfirmationDialog,
|
||||
FileContextMenu,
|
||||
TrashIcon,
|
||||
CopyIcon,
|
||||
EditIcon,
|
||||
NewFileDialog,
|
||||
} from '~/components/workspace'
|
||||
import { NavTabs } from '~/components/navigation'
|
||||
import { CrabIdleAnimation } from '~/components/ani'
|
||||
@@ -356,7 +363,174 @@ function WorkspacePage() {
|
||||
})
|
||||
}, [])
|
||||
|
||||
// Delete confirmation dialog state
|
||||
const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false)
|
||||
const [fileToDelete, setFileToDelete] = useState<string | null>(null)
|
||||
|
||||
// New file dialog state
|
||||
const [newFileDialogOpen, setNewFileDialogOpen] = useState(false)
|
||||
const [newFileFolderPath, setNewFileFolderPath] = useState<string | null>(null)
|
||||
|
||||
// Context menu state
|
||||
const [contextMenuOpen, setContextMenuOpen] = useState(false)
|
||||
const [contextMenuPosition, setContextMenuPosition] = useState<{ x: number; y: number } | null>(null)
|
||||
const [contextMenuFilePath, setContextMenuFilePath] = useState<string | null>(null)
|
||||
|
||||
// Clipboard feedback state
|
||||
const [copyFeedback, setCopyFeedback] = useState<'idle' | 'copied' | 'failed'>('idle')
|
||||
|
||||
// Handle save file with confirmation callback
|
||||
const handleSave = useCallback(
|
||||
async (content: string, callback: (success: boolean) => void) => {
|
||||
if (!selectedPath) {
|
||||
callback(false)
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
await trpc.workspace.writeFile.mutate({
|
||||
workspaceRoot: workspacePath,
|
||||
path: selectedPath,
|
||||
content,
|
||||
})
|
||||
// Reload the file to get updated metadata
|
||||
await loadFile(selectedPath)
|
||||
// Refresh the directory to update metadata
|
||||
await handleRefresh()
|
||||
callback(true)
|
||||
} catch (error) {
|
||||
console.error('Failed to save file:', error)
|
||||
callback(false)
|
||||
}
|
||||
},
|
||||
[selectedPath, workspacePath, loadFile, handleRefresh]
|
||||
)
|
||||
|
||||
// Handle delete file
|
||||
const handleDeleteFile = useCallback(async () => {
|
||||
if (!fileToDelete) return
|
||||
|
||||
try {
|
||||
await trpc.workspace.deleteFile.mutate({
|
||||
workspaceRoot: workspacePath,
|
||||
path: fileToDelete,
|
||||
})
|
||||
|
||||
// If deleted file was selected, clear selection
|
||||
if (selectedPath === fileToDelete) {
|
||||
setSelectedPath(null)
|
||||
setSelectedFileContent('')
|
||||
setSelectedFileName('')
|
||||
setSelectedFileSize(undefined)
|
||||
setSelectedFileModified(undefined)
|
||||
}
|
||||
|
||||
// Clear cache and refresh
|
||||
setPathCache(new Map())
|
||||
await handleRefresh()
|
||||
} catch (error) {
|
||||
console.error('Failed to delete file:', error)
|
||||
} finally {
|
||||
setFileToDelete(null)
|
||||
setDeleteConfirmOpen(false)
|
||||
}
|
||||
}, [fileToDelete, workspacePath, selectedPath, handleRefresh])
|
||||
|
||||
// Handle create file (uses newFileFolderPath if set)
|
||||
const handleCreateFile = useCallback(
|
||||
async (fileName: string, content: string) => {
|
||||
try {
|
||||
// If creating in a subfolder, prepend the relative path
|
||||
let finalFileName = fileName
|
||||
if (newFileFolderPath && newFileFolderPath !== workspacePath) {
|
||||
const relativePath = newFileFolderPath.replace(workspacePath + '/', '')
|
||||
finalFileName = `${relativePath}/${fileName}`
|
||||
}
|
||||
|
||||
const result = await trpc.workspace.createFile.mutate({
|
||||
workspaceRoot: workspacePath,
|
||||
fileName: finalFileName,
|
||||
content,
|
||||
})
|
||||
|
||||
if (result.success && result.filePath) {
|
||||
// Clear cache and refresh
|
||||
setPathCache(new Map())
|
||||
await handleRefresh()
|
||||
|
||||
// Select the new file
|
||||
setSelectedPath(result.filePath)
|
||||
await loadFile(result.filePath)
|
||||
} else if (result.error) {
|
||||
throw new Error(result.error)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to create file:', error)
|
||||
throw error
|
||||
} finally {
|
||||
setNewFileFolderPath(null)
|
||||
}
|
||||
},
|
||||
[workspacePath, newFileFolderPath, loadFile, handleRefresh]
|
||||
)
|
||||
|
||||
// Handle context menu
|
||||
const handleContextMenu = useCallback(
|
||||
(e: React.MouseEvent, filePath: string) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
setContextMenuFilePath(filePath)
|
||||
setContextMenuPosition({ x: e.clientX, y: e.clientY })
|
||||
setContextMenuOpen(true)
|
||||
},
|
||||
[]
|
||||
)
|
||||
|
||||
// Handle create file in folder (from + button in tree)
|
||||
const handleCreateFileInFolder = useCallback((folderPath: string) => {
|
||||
setNewFileFolderPath(folderPath)
|
||||
setNewFileDialogOpen(true)
|
||||
}, [])
|
||||
|
||||
// Context menu items
|
||||
const contextMenuItems = useMemo(() => [
|
||||
{
|
||||
icon: <EditIcon size={14} />,
|
||||
label: 'Edit',
|
||||
onClick: () => {
|
||||
if (contextMenuFilePath) {
|
||||
handleSelect(contextMenuFilePath, 'file')
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
icon: <CopyIcon size={14} />,
|
||||
label: copyFeedback === 'copied' ? 'Copied!' : copyFeedback === 'failed' ? 'Failed' : 'Copy Path',
|
||||
onClick: async () => {
|
||||
if (contextMenuFilePath) {
|
||||
try {
|
||||
await navigator.clipboard.writeText(contextMenuFilePath)
|
||||
setCopyFeedback('copied')
|
||||
setTimeout(() => setCopyFeedback('idle'), 2000)
|
||||
} catch {
|
||||
setCopyFeedback('failed')
|
||||
setTimeout(() => setCopyFeedback('idle'), 2000)
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
icon: <TrashIcon size={14} />,
|
||||
label: 'Delete',
|
||||
danger: true,
|
||||
onClick: () => {
|
||||
if (contextMenuFilePath) {
|
||||
setFileToDelete(contextMenuFilePath)
|
||||
setDeleteConfirmOpen(true)
|
||||
}
|
||||
},
|
||||
},
|
||||
], [contextMenuFilePath, handleSelect, copyFeedback])
|
||||
|
||||
return (
|
||||
<div className="h-screen flex flex-col bg-shell-950 text-white overflow-hidden">
|
||||
@@ -436,14 +610,14 @@ function WorkspacePage() {
|
||||
transition={{ duration: 0.2, ease: 'easeInOut' }}
|
||||
className="border-r border-shell-800 bg-shell-900/50 flex flex-col overflow-hidden"
|
||||
>
|
||||
{/* Sidebar header */}
|
||||
{/* Sidebar header */}
|
||||
<div className={`flex items-center justify-between px-3 py-3 border-b border-shell-800 ${sidebarCollapsed ? 'justify-center' : ''}`}>
|
||||
{!sidebarCollapsed && (
|
||||
<span className="font-display text-xs text-shell-500 uppercase tracking-wider">
|
||||
Files
|
||||
</span>
|
||||
)}
|
||||
<div className={`flex items-center gap-2 ${sidebarCollapsed ? 'mx-auto' : ''}`}>
|
||||
<div className={`flex items-center gap-1 ${sidebarCollapsed ? 'mx-auto flex-col' : ''}`}>
|
||||
{loading && !sidebarCollapsed && (
|
||||
<motion.div
|
||||
animate={{ rotate: 360 }}
|
||||
@@ -452,6 +626,15 @@ function WorkspacePage() {
|
||||
<RefreshCw size={14} className="text-shell-500" />
|
||||
</motion.div>
|
||||
)}
|
||||
{!sidebarCollapsed && (
|
||||
<button
|
||||
onClick={() => setNewFileDialogOpen(true)}
|
||||
className="p-1.5 hover:bg-shell-800 rounded transition-colors"
|
||||
title="New File"
|
||||
>
|
||||
<Plus size={16} className="text-shell-500 hover:text-neon-mint" />
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={() => setSidebarCollapsed(!sidebarCollapsed)}
|
||||
className="p-1.5 hover:bg-shell-800 rounded transition-colors"
|
||||
@@ -497,7 +680,7 @@ function WorkspacePage() {
|
||||
)
|
||||
})}
|
||||
{starredPaths.size > 5 && (
|
||||
<span className="text-[10px] text-shell-500">+{starredPaths.size - 5}</span>
|
||||
<span className="text-[11px] text-shell-500">+{starredPaths.size - 5}</span>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
@@ -519,7 +702,7 @@ function WorkspacePage() {
|
||||
>
|
||||
<FileText
|
||||
size={14}
|
||||
className={`flex-shrink-0 ${
|
||||
className={`shrink-0 ${
|
||||
ext === '.md' ? 'text-crab-400' : 'text-shell-500'
|
||||
}`}
|
||||
/>
|
||||
@@ -531,7 +714,7 @@ function WorkspacePage() {
|
||||
e.stopPropagation()
|
||||
handleStar(filePath)
|
||||
}}
|
||||
className="text-yellow-400 hover:text-yellow-300 flex-shrink-0"
|
||||
className="text-yellow-400 hover:text-yellow-300 shrink-0"
|
||||
title="Unstar file"
|
||||
>
|
||||
<Star size={14} fill="currentColor" />
|
||||
@@ -544,17 +727,19 @@ function WorkspacePage() {
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* File tree */}
|
||||
{!sidebarCollapsed && (
|
||||
<div className="flex-1 overflow-y-auto overflow-x-hidden py-2">
|
||||
{pathValid ? (
|
||||
<FileTree
|
||||
entries={rootEntries}
|
||||
selectedPath={selectedPath}
|
||||
onSelect={handleSelect}
|
||||
onLoadDirectory={handleLoadDirectory}
|
||||
/>
|
||||
) : (
|
||||
{/* File tree */}
|
||||
{!sidebarCollapsed && (
|
||||
<div className="flex-1 overflow-y-auto overflow-x-hidden py-2">
|
||||
{pathValid ? (
|
||||
<FileTree
|
||||
entries={rootEntries}
|
||||
selectedPath={selectedPath}
|
||||
onSelect={handleSelect}
|
||||
onLoadDirectory={handleLoadDirectory}
|
||||
onContextMenu={handleContextMenu}
|
||||
onCreateFile={handleCreateFileInFolder}
|
||||
/>
|
||||
) : (
|
||||
<div className="p-4 text-center">
|
||||
<p className="font-console text-xs text-shell-500">
|
||||
Enter a workspace path to browse files
|
||||
@@ -564,10 +749,10 @@ function WorkspacePage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Sidebar footer */}
|
||||
{/* Sidebar footer */}
|
||||
{pathValid && !sidebarCollapsed && (
|
||||
<div className="px-4 py-2 border-t border-shell-800">
|
||||
<p className="font-console text-[10px] text-shell-600 truncate">
|
||||
<p className="font-console text-[11px] text-shell-600 truncate">
|
||||
{workspacePath}
|
||||
</p>
|
||||
</div>
|
||||
@@ -575,9 +760,9 @@ function WorkspacePage() {
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{/* Main content area */}
|
||||
{/* Main content area */}
|
||||
<div className={`flex-1 relative bg-shell-950 ${isMobile ? 'pb-20' : ''}`}>
|
||||
<MarkdownViewer
|
||||
<FileEditor
|
||||
content={selectedFileContent}
|
||||
fileName={selectedFileName}
|
||||
filePath={selectedPath ?? undefined}
|
||||
@@ -586,6 +771,7 @@ function WorkspacePage() {
|
||||
error={fileError}
|
||||
isStarred={selectedPath ? starredPaths.has(selectedPath) : false}
|
||||
onStar={handleStar}
|
||||
onSave={handleSave}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -627,6 +813,40 @@ function WorkspacePage() {
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Delete confirmation dialog */}
|
||||
<ConfirmationDialog
|
||||
open={deleteConfirmOpen}
|
||||
title="Delete File"
|
||||
message={`Are you sure you want to delete "${fileToDelete?.split('/').pop()}"? This action cannot be undone.`}
|
||||
confirmText="Delete"
|
||||
cancelText="Cancel"
|
||||
onConfirm={handleDeleteFile}
|
||||
onCancel={() => {
|
||||
setDeleteConfirmOpen(false)
|
||||
setFileToDelete(null)
|
||||
}}
|
||||
variant="danger"
|
||||
/>
|
||||
|
||||
{/* New file dialog */}
|
||||
<NewFileDialog
|
||||
open={newFileDialogOpen}
|
||||
onClose={() => {
|
||||
setNewFileDialogOpen(false)
|
||||
setNewFileFolderPath(null)
|
||||
}}
|
||||
onCreate={handleCreateFile}
|
||||
folderPath={newFileFolderPath ?? undefined}
|
||||
/>
|
||||
|
||||
{/* Context menu */}
|
||||
<FileContextMenu
|
||||
open={contextMenuOpen}
|
||||
position={contextMenuPosition}
|
||||
items={contextMenuItems}
|
||||
onClose={() => setContextMenuOpen(false)}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user