diff --git a/.env.example b/.env.example index 48dadc9..bd61d84 100644 --- a/.env.example +++ b/.env.example @@ -8,3 +8,7 @@ CLAWDBOT_GATEWAY_URL=ws://127.0.0.1:18789 CLAWDBOT_GATEWAY_TOKEN= # Alternative: # CLAWDBOT_GATEWAY_PASSWORD= + +# File Explorer - root directory for file browsing +# Defaults to the user's home directory if not set. +FILES_ROOT=/home/agustin diff --git a/.gitignore b/.gitignore index 6221ecb..f9c2d6a 100644 --- a/.gitignore +++ b/.gitignore @@ -11,3 +11,4 @@ count.txt .output .vinxi todos.json +webclaw diff --git a/README.md b/README.md index 67350ba..a076c09 100644 --- a/README.md +++ b/README.md @@ -1,21 +1,71 @@ # WebClaw -![Cover](https://raw.githubusercontent.com/ibelick/webclaw/main/apps/webclaw/public/cover.jpg) +![Cover](./public/cover.webp) -Fast web client for OpenClaw. - -[webclaw.dev](https://webclaw.dev) +Fast web client for OpenClaw Currently in beta. +## Features + +- πŸ’¬ **Chat** β€” Real-time chat with your OpenClaw agent +- πŸ“ **File Explorer** β€” Browse, upload, download, rename and delete files on the host filesystem +- ✏️ **Text Editor** β€” Edit YAML, JSON, Markdown, Python, JavaScript, and 30+ file types directly in the browser (Ctrl+S to save) + ## Setup -Create `apps/webclaw/.env.local` with `CLAWDBOT_GATEWAY_URL` and either -`CLAWDBOT_GATEWAY_TOKEN` (recommended) or `CLAWDBOT_GATEWAY_PASSWORD`. These map -to your OpenClaw Gateway auth (`gateway.auth.token` or `gateway.auth.password`). -Default URL is `ws://127.0.0.1:18789`. Docs: https://docs.openclaw.ai/gateway +### 1. Install dependencies ```bash -pnpm install -pnpm dev +npm install +``` + +### 2. Configure environment + +Create a `.env` file in the project root: + +```env +# Gateway connection (required) +CLAWDBOT_GATEWAY_URL=ws://127.0.0.1:18789 +CLAWDBOT_GATEWAY_TOKEN=your_gateway_token_here +# Or use password auth: +# CLAWDBOT_GATEWAY_PASSWORD=your_password_here + +# File Explorer β€” root directory for browsing (optional) +# Defaults to the current user's home directory if not set. +FILES_ROOT=/home/your_user ``` + +| Variable | Required | Description | +|---|---|---| +| `CLAWDBOT_GATEWAY_URL` | No | WebSocket URL for the OpenClaw Gateway. Default: `ws://127.0.0.1:18789` | +| `CLAWDBOT_GATEWAY_TOKEN` | Yes* | Gateway auth token (`gateway.auth.token` in OpenClaw config) | +| `CLAWDBOT_GATEWAY_PASSWORD` | Yes* | Alternative: Gateway password (`gateway.auth.password`) | +| `FILES_ROOT` | No | Absolute path to the root directory for the file explorer. Default: `$HOME` | + +\* One of `CLAWDBOT_GATEWAY_TOKEN` or `CLAWDBOT_GATEWAY_PASSWORD` is required. + +### 3. Run + +```bash +npm run dev +``` + +The app will be available at `http://localhost:3000`. Use `--host` to expose on the network: + +```bash +npm run dev -- --host +``` + +## File Explorer + +The file explorer reads and writes directly to the host filesystem under the `FILES_ROOT` directory. Key points: + +- **Security**: All paths are jailed to `FILES_ROOT` β€” directory traversal and symlink escapes are blocked. +- **Hidden files**: Visible by default (dotfiles are shown). +- **Text editing**: Double-click any supported text file to open the built-in editor. Supported extensions include `.yaml`, `.yml`, `.json`, `.md`, `.txt`, `.py`, `.js`, `.ts`, `.sh`, `.html`, `.css`, and many more. +- **Context menu**: Right-click any file or folder for actions (Open, Edit, Download, Rename, Delete). + +## Docs + +Gateway auth docs: https://docs.openclaw.ai/gateway diff --git a/apps/webclaw/public/screenshot-file-explorer.png b/apps/webclaw/public/screenshot-file-explorer.png new file mode 100644 index 0000000..43786a3 Binary files /dev/null and b/apps/webclaw/public/screenshot-file-explorer.png differ diff --git a/apps/webclaw/src/lib/file-types.ts b/apps/webclaw/src/lib/file-types.ts new file mode 100644 index 0000000..46020c8 --- /dev/null +++ b/apps/webclaw/src/lib/file-types.ts @@ -0,0 +1,27 @@ +export type FileItem = { + path: string + name: string + size: number + extension: string + modified: string + mode: number + isDir: boolean + isSymlink: boolean + type: string // 'directory' | 'text' | 'image' | 'video' | 'audio' | 'blob' +} + +export type FileListing = { + items: FileItem[] + path: string + name: string + isDir: boolean + size: number + modified: string + mode: number + numDirs: number + numFiles: number + sorting: { + by: string + asc: boolean + } +} diff --git a/apps/webclaw/src/routeTree.gen.ts b/apps/webclaw/src/routeTree.gen.ts index 97661c6..0553190 100644 --- a/apps/webclaw/src/routeTree.gen.ts +++ b/apps/webclaw/src/routeTree.gen.ts @@ -10,6 +10,7 @@ import { Route as rootRouteImport } from './routes/__root' import { Route as NewRouteImport } from './routes/new' +import { Route as FilesRouteImport } from './routes/files' import { Route as ConnectRouteImport } from './routes/connect' import { Route as IndexRouteImport } from './routes/index' import { Route as ChatSessionKeyRouteImport } from './routes/chat/$sessionKey' @@ -18,12 +19,24 @@ import { Route as ApiSendRouteImport } from './routes/api/send' import { Route as ApiPingRouteImport } from './routes/api/ping' import { Route as ApiPathsRouteImport } from './routes/api/paths' import { Route as ApiHistoryRouteImport } from './routes/api/history' +import { Route as ApiFilesListRouteImport } from './routes/api/files/list' +import { Route as ApiFilesInfoRouteImport } from './routes/api/files/info' +import { Route as ApiFilesDownloadRouteImport } from './routes/api/files/download' +import { Route as ApiFilesUploadRouteImport } from './routes/api/files/upload' +import { Route as ApiFilesDeleteRouteImport } from './routes/api/files/delete' +import { Route as ApiFilesMkdirRouteImport } from './routes/api/files/mkdir' +import { Route as ApiFilesRenameRouteImport } from './routes/api/files/rename' const NewRoute = NewRouteImport.update({ id: '/new', path: '/new', getParentRoute: () => rootRouteImport, } as any) +const FilesRoute = FilesRouteImport.update({ + id: '/files', + path: '/files', + getParentRoute: () => rootRouteImport, +} as any) const ConnectRoute = ConnectRouteImport.update({ id: '/connect', path: '/connect', @@ -64,10 +77,46 @@ const ApiHistoryRoute = ApiHistoryRouteImport.update({ path: '/api/history', getParentRoute: () => rootRouteImport, } as any) +const ApiFilesListRoute = ApiFilesListRouteImport.update({ + id: '/api/files/list', + path: '/api/files/list', + getParentRoute: () => rootRouteImport, +} as any) +const ApiFilesInfoRoute = ApiFilesInfoRouteImport.update({ + id: '/api/files/info', + path: '/api/files/info', + getParentRoute: () => rootRouteImport, +} as any) +const ApiFilesDownloadRoute = ApiFilesDownloadRouteImport.update({ + id: '/api/files/download', + path: '/api/files/download', + getParentRoute: () => rootRouteImport, +} as any) +const ApiFilesUploadRoute = ApiFilesUploadRouteImport.update({ + id: '/api/files/upload', + path: '/api/files/upload', + getParentRoute: () => rootRouteImport, +} as any) +const ApiFilesDeleteRoute = ApiFilesDeleteRouteImport.update({ + id: '/api/files/delete', + path: '/api/files/delete', + getParentRoute: () => rootRouteImport, +} as any) +const ApiFilesMkdirRoute = ApiFilesMkdirRouteImport.update({ + id: '/api/files/mkdir', + path: '/api/files/mkdir', + getParentRoute: () => rootRouteImport, +} as any) +const ApiFilesRenameRoute = ApiFilesRenameRouteImport.update({ + id: '/api/files/rename', + path: '/api/files/rename', + getParentRoute: () => rootRouteImport, +} as any) export interface FileRoutesByFullPath { '/': typeof IndexRoute '/connect': typeof ConnectRoute + '/files': typeof FilesRoute '/new': typeof NewRoute '/api/history': typeof ApiHistoryRoute '/api/paths': typeof ApiPathsRoute @@ -75,10 +124,18 @@ export interface FileRoutesByFullPath { '/api/send': typeof ApiSendRoute '/api/sessions': typeof ApiSessionsRoute '/chat/$sessionKey': typeof ChatSessionKeyRoute + '/api/files/list': typeof ApiFilesListRoute + '/api/files/info': typeof ApiFilesInfoRoute + '/api/files/download': typeof ApiFilesDownloadRoute + '/api/files/upload': typeof ApiFilesUploadRoute + '/api/files/delete': typeof ApiFilesDeleteRoute + '/api/files/mkdir': typeof ApiFilesMkdirRoute + '/api/files/rename': typeof ApiFilesRenameRoute } export interface FileRoutesByTo { '/': typeof IndexRoute '/connect': typeof ConnectRoute + '/files': typeof FilesRoute '/new': typeof NewRoute '/api/history': typeof ApiHistoryRoute '/api/paths': typeof ApiPathsRoute @@ -86,11 +143,19 @@ export interface FileRoutesByTo { '/api/send': typeof ApiSendRoute '/api/sessions': typeof ApiSessionsRoute '/chat/$sessionKey': typeof ChatSessionKeyRoute + '/api/files/list': typeof ApiFilesListRoute + '/api/files/info': typeof ApiFilesInfoRoute + '/api/files/download': typeof ApiFilesDownloadRoute + '/api/files/upload': typeof ApiFilesUploadRoute + '/api/files/delete': typeof ApiFilesDeleteRoute + '/api/files/mkdir': typeof ApiFilesMkdirRoute + '/api/files/rename': typeof ApiFilesRenameRoute } export interface FileRoutesById { __root__: typeof rootRouteImport '/': typeof IndexRoute '/connect': typeof ConnectRoute + '/files': typeof FilesRoute '/new': typeof NewRoute '/api/history': typeof ApiHistoryRoute '/api/paths': typeof ApiPathsRoute @@ -98,12 +163,20 @@ export interface FileRoutesById { '/api/send': typeof ApiSendRoute '/api/sessions': typeof ApiSessionsRoute '/chat/$sessionKey': typeof ChatSessionKeyRoute + '/api/files/list': typeof ApiFilesListRoute + '/api/files/info': typeof ApiFilesInfoRoute + '/api/files/download': typeof ApiFilesDownloadRoute + '/api/files/upload': typeof ApiFilesUploadRoute + '/api/files/delete': typeof ApiFilesDeleteRoute + '/api/files/mkdir': typeof ApiFilesMkdirRoute + '/api/files/rename': typeof ApiFilesRenameRoute } export interface FileRouteTypes { fileRoutesByFullPath: FileRoutesByFullPath fullPaths: | '/' | '/connect' + | '/files' | '/new' | '/api/history' | '/api/paths' @@ -111,10 +184,18 @@ export interface FileRouteTypes { | '/api/send' | '/api/sessions' | '/chat/$sessionKey' + | '/api/files/list' + | '/api/files/info' + | '/api/files/download' + | '/api/files/upload' + | '/api/files/delete' + | '/api/files/mkdir' + | '/api/files/rename' fileRoutesByTo: FileRoutesByTo to: | '/' | '/connect' + | '/files' | '/new' | '/api/history' | '/api/paths' @@ -122,10 +203,18 @@ export interface FileRouteTypes { | '/api/send' | '/api/sessions' | '/chat/$sessionKey' + | '/api/files/list' + | '/api/files/info' + | '/api/files/download' + | '/api/files/upload' + | '/api/files/delete' + | '/api/files/mkdir' + | '/api/files/rename' id: | '__root__' | '/' | '/connect' + | '/files' | '/new' | '/api/history' | '/api/paths' @@ -133,11 +222,19 @@ export interface FileRouteTypes { | '/api/send' | '/api/sessions' | '/chat/$sessionKey' + | '/api/files/list' + | '/api/files/info' + | '/api/files/download' + | '/api/files/upload' + | '/api/files/delete' + | '/api/files/mkdir' + | '/api/files/rename' fileRoutesById: FileRoutesById } export interface RootRouteChildren { IndexRoute: typeof IndexRoute ConnectRoute: typeof ConnectRoute + FilesRoute: typeof FilesRoute NewRoute: typeof NewRoute ApiHistoryRoute: typeof ApiHistoryRoute ApiPathsRoute: typeof ApiPathsRoute @@ -145,6 +242,13 @@ export interface RootRouteChildren { ApiSendRoute: typeof ApiSendRoute ApiSessionsRoute: typeof ApiSessionsRoute ChatSessionKeyRoute: typeof ChatSessionKeyRoute + ApiFilesListRoute: typeof ApiFilesListRoute + ApiFilesInfoRoute: typeof ApiFilesInfoRoute + ApiFilesDownloadRoute: typeof ApiFilesDownloadRoute + ApiFilesUploadRoute: typeof ApiFilesUploadRoute + ApiFilesDeleteRoute: typeof ApiFilesDeleteRoute + ApiFilesMkdirRoute: typeof ApiFilesMkdirRoute + ApiFilesRenameRoute: typeof ApiFilesRenameRoute } declare module '@tanstack/react-router' { @@ -156,6 +260,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof NewRouteImport parentRoute: typeof rootRouteImport } + '/files': { + id: '/files' + path: '/files' + fullPath: '/files' + preLoaderRoute: typeof FilesRouteImport + parentRoute: typeof rootRouteImport + } '/connect': { id: '/connect' path: '/connect' @@ -212,12 +323,62 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof ApiHistoryRouteImport parentRoute: typeof rootRouteImport } + '/api/files/list': { + id: '/api/files/list' + path: '/api/files/list' + fullPath: '/api/files/list' + preLoaderRoute: typeof ApiFilesListRouteImport + parentRoute: typeof rootRouteImport + } + '/api/files/info': { + id: '/api/files/info' + path: '/api/files/info' + fullPath: '/api/files/info' + preLoaderRoute: typeof ApiFilesInfoRouteImport + parentRoute: typeof rootRouteImport + } + '/api/files/download': { + id: '/api/files/download' + path: '/api/files/download' + fullPath: '/api/files/download' + preLoaderRoute: typeof ApiFilesDownloadRouteImport + parentRoute: typeof rootRouteImport + } + '/api/files/upload': { + id: '/api/files/upload' + path: '/api/files/upload' + fullPath: '/api/files/upload' + preLoaderRoute: typeof ApiFilesUploadRouteImport + parentRoute: typeof rootRouteImport + } + '/api/files/delete': { + id: '/api/files/delete' + path: '/api/files/delete' + fullPath: '/api/files/delete' + preLoaderRoute: typeof ApiFilesDeleteRouteImport + parentRoute: typeof rootRouteImport + } + '/api/files/mkdir': { + id: '/api/files/mkdir' + path: '/api/files/mkdir' + fullPath: '/api/files/mkdir' + preLoaderRoute: typeof ApiFilesMkdirRouteImport + parentRoute: typeof rootRouteImport + } + '/api/files/rename': { + id: '/api/files/rename' + path: '/api/files/rename' + fullPath: '/api/files/rename' + preLoaderRoute: typeof ApiFilesRenameRouteImport + parentRoute: typeof rootRouteImport + } } } const rootRouteChildren: RootRouteChildren = { IndexRoute: IndexRoute, ConnectRoute: ConnectRoute, + FilesRoute: FilesRoute, NewRoute: NewRoute, ApiHistoryRoute: ApiHistoryRoute, ApiPathsRoute: ApiPathsRoute, @@ -225,6 +386,13 @@ const rootRouteChildren: RootRouteChildren = { ApiSendRoute: ApiSendRoute, ApiSessionsRoute: ApiSessionsRoute, ChatSessionKeyRoute: ChatSessionKeyRoute, + ApiFilesListRoute: ApiFilesListRoute, + ApiFilesInfoRoute: ApiFilesInfoRoute, + ApiFilesDownloadRoute: ApiFilesDownloadRoute, + ApiFilesUploadRoute: ApiFilesUploadRoute, + ApiFilesDeleteRoute: ApiFilesDeleteRoute, + ApiFilesMkdirRoute: ApiFilesMkdirRoute, + ApiFilesRenameRoute: ApiFilesRenameRoute, } export const routeTree = rootRouteImport ._addFileChildren(rootRouteChildren) diff --git a/apps/webclaw/src/routes/api/cron.ts b/apps/webclaw/src/routes/api/cron.ts new file mode 100644 index 0000000..0172ccf --- /dev/null +++ b/apps/webclaw/src/routes/api/cron.ts @@ -0,0 +1,128 @@ +import { createFileRoute } from '@tanstack/react-router' +import { json } from '@tanstack/react-start' +import { gatewayRpc } from '../../server/gateway' + +type CronJob = { + id: string + label?: string + schedule?: string + enabled?: boolean + lastRun?: string + nextRun?: string + [key: string]: unknown +} + +type CronListResponse = { + jobs?: Array +} + +type CronRunsResponse = { + runs?: Array> +} + +type CronRunResponse = { + ok?: boolean + [key: string]: unknown +} + +type CronUpdateResponse = { + ok?: boolean + job?: CronJob + [key: string]: unknown +} + +export const Route = createFileRoute('/api/cron')({ + server: { + handlers: { + GET: async ({ request }) => { + try { + const url = new URL(request.url) + const jobId = url.searchParams.get('jobId') + + if (jobId) { + const payload = await gatewayRpc('cron.runs', { + jobId, + }) + return json({ runs: payload.runs ?? [] }) + } + + const payload = await gatewayRpc('cron.list', { + includeDisabled: true, + }) + return json({ jobs: payload.jobs ?? [] }) + } catch (err) { + return json( + { error: err instanceof Error ? err.message : String(err) }, + { status: 500 }, + ) + } + }, + POST: async ({ request }) => { + try { + const body = (await request.json().catch(() => ({}))) as Record< + string, + unknown + > + + const jobId = typeof body.jobId === 'string' ? body.jobId.trim() : '' + if (!jobId) { + return json( + { ok: false, error: 'jobId is required' }, + { status: 400 }, + ) + } + + const payload = await gatewayRpc('cron.run', { + jobId, + }) + + return json({ ok: true, ...payload }) + } catch (err) { + return json( + { + ok: false, + error: err instanceof Error ? err.message : String(err), + }, + { status: 500 }, + ) + } + }, + PATCH: async ({ request }) => { + try { + const body = (await request.json().catch(() => ({}))) as Record< + string, + unknown + > + + const jobId = typeof body.jobId === 'string' ? body.jobId.trim() : '' + if (!jobId) { + return json( + { ok: false, error: 'jobId is required' }, + { status: 400 }, + ) + } + + const patch = + typeof body.patch === 'object' && body.patch !== null + ? body.patch + : {} + + const payload = await gatewayRpc('cron.update', { + jobId, + patch, + }) + + return json({ ok: true, ...payload }) + } catch (err) { + return json( + { + ok: false, + error: err instanceof Error ? err.message : String(err), + }, + { status: 500 }, + ) + } + }, + }, + }, +}) diff --git a/apps/webclaw/src/routes/api/files/delete.ts b/apps/webclaw/src/routes/api/files/delete.ts new file mode 100644 index 0000000..6e75826 --- /dev/null +++ b/apps/webclaw/src/routes/api/files/delete.ts @@ -0,0 +1,86 @@ +import { createFileRoute } from '@tanstack/react-router' +import { json } from '@tanstack/react-start' +import { deleteFile } from '../../../server/filesystem' +import { validatePath } from '../../../server/path-utils' +import type { FileSystemError } from '../../../server/filesystem' + +type DeleteResponse = { + ok: true + message: string + path: string +} + +type ErrorResponse = { + error: string + code: string +} + +export const Route = createFileRoute('/api/files/delete')({ + server: { + handlers: { + DELETE: async ({ request }) => { + try { + const url = new URL(request.url) + const rawPath = url.searchParams.get('path') + + if (!rawPath) { + return json( + { + error: 'path parameter is required', + code: 'MISSING_PATH', + }, + { status: 400 } + ) + } + + // Validate and sanitize the path + const path = validatePath(rawPath, 'Path parameter') + + // Prevent deleting root path + if (path === '/' || path === '') { + return json( + { + error: 'Cannot delete the root directory', + code: 'FORBIDDEN_DELETE_ROOT', + }, + { status: 403 } // Forbidden + ) + } + + await deleteFile(path) + + return json({ + ok: true, + message: 'File deleted successfully', + path, + }) + } catch (err) { + const error = err as Error & FileSystemError + + if (error.message.includes('invalid characters') || + error.message.includes('traversal attempts') || + error.message.includes('Cannot delete')) { + return json( + { + error: error.message, + code: 'VALIDATION_ERROR', + }, + { status: 400 }, + ) + } + + const status = error.status || 500 + const code = error.code || 'INTERNAL_ERROR' + + return json( + { + error: error.message || 'An unexpected error occurred', + code, + }, + { status }, + ) + } + }, + }, + }, +}) \ No newline at end of file diff --git a/apps/webclaw/src/routes/api/files/download.ts b/apps/webclaw/src/routes/api/files/download.ts new file mode 100644 index 0000000..7a47b55 --- /dev/null +++ b/apps/webclaw/src/routes/api/files/download.ts @@ -0,0 +1,159 @@ +import { createFileRoute } from '@tanstack/react-router' +import { downloadFile, getFileInfo } from '../../../server/filesystem' +import { validatePath } from '../../../server/path-utils' +import type { FileSystemError } from '../../../server/filesystem' + +type ErrorResponse = { + error: string + code: string +} + +// Expanded MIME type map +function getContentType(filename: string): string { + const ext = filename.split('.').pop()?.toLowerCase() || '' + const mimeTypes: Record = { + // Text files + 'txt': 'text/plain', + 'md': 'text/markdown', + 'html': 'text/html', + 'css': 'text/css', + 'js': 'text/javascript', + 'json': 'application/json', + 'xml': 'application/xml', + 'csv': 'text/csv', + 'yaml': 'application/x-yaml', + 'yml': 'application/x-yaml', + + // Programming languages + 'ts': 'application/typescript', + 'tsx': 'application/typescript', + 'py': 'text/x-python', + + // Images + 'png': 'image/png', + 'jpg': 'image/jpeg', + 'jpeg': 'image/jpeg', + 'gif': 'image/gif', + 'webp': 'image/webp', + 'svg': 'image/svg+xml', + + // Audio/Video + 'mp3': 'audio/mpeg', + 'mp4': 'video/mp4', + + // Documents + 'pdf': 'application/pdf', + 'doc': 'application/msword', + 'docx': 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', + 'xls': 'application/vnd.ms-excel', + 'xlsx': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + 'ppt': 'application/vnd.ms-powerpoint', + 'pptx': 'application/vnd.openxmlformats-officedocument.presentationml.presentation', + + // Archives + 'zip': 'application/zip', + 'tar.gz': 'application/gzip', + 'gz': 'application/gzip', + + // Fonts + 'woff': 'font/woff', + 'woff2': 'font/woff2', + 'ttf': 'font/ttf', + 'otf': 'font/otf', + } + + return mimeTypes[ext] || 'application/octet-stream' +} + +function encodeFilename(filename: string): string { + // RFC 6266 compliant UTF-8 filename encoding + const encodedFilename = encodeURIComponent(filename) + return `filename*=UTF-8''${encodedFilename}` +} + +function isStaticAsset(filename: string): boolean { + const ext = filename.split('.').pop()?.toLowerCase() || '' + const staticExts = ['png', 'jpg', 'jpeg', 'gif', 'webp', 'svg', 'css', 'js', 'woff', 'woff2', 'ttf', 'otf'] + return staticExts.includes(ext) +} + +export const Route = createFileRoute('/api/files/download')({ + server: { + handlers: { + GET: async ({ request }) => { + try { + const url = new URL(request.url) + const rawPath = url.searchParams.get('path') + + if (!rawPath) { + return new Response( + JSON.stringify({ + error: 'path parameter is required', + code: 'MISSING_PATH', + } as ErrorResponse), + { + status: 400, + headers: { 'Content-Type': 'application/json' }, + } + ) + } + + // Validate and sanitize the path + const path = validatePath(rawPath, 'Path parameter') + + // Get file info to determine mime type and filename + const fileInfo = await getFileInfo(path) + const content = await downloadFile(path) + + const contentType = getContentType(fileInfo.name) + const contentDisposition = `attachment; ${encodeFilename(fileInfo.name)}` + + const headers: Record = { + 'Content-Type': contentType, + 'Content-Disposition': contentDisposition, + 'Content-Length': content.byteLength.toString(), + } + + // Add cache control for static assets + if (isStaticAsset(fileInfo.name)) { + headers['Cache-Control'] = 'public, max-age=31536000' // 1 year + } else { + headers['Cache-Control'] = 'no-cache' + } + + return new Response(content, { headers }) + } catch (err) { + const error = err as Error & FileSystemError + + if (error.message.includes('invalid characters') || + error.message.includes('traversal attempts')) { + return new Response( + JSON.stringify({ + error: error.message, + code: 'INVALID_PATH', + } as ErrorResponse), + { + status: 400, + headers: { 'Content-Type': 'application/json' }, + } + ) + } + + const status = error.status || 500 + const code = error.code || 'INTERNAL_ERROR' + + return new Response( + JSON.stringify({ + error: error.message || 'An unexpected error occurred', + code, + } as ErrorResponse), + { + status, + headers: { 'Content-Type': 'application/json' }, + } + ) + } + }, + }, + }, +}) \ No newline at end of file diff --git a/apps/webclaw/src/routes/api/files/info.ts b/apps/webclaw/src/routes/api/files/info.ts new file mode 100644 index 0000000..5faa28e --- /dev/null +++ b/apps/webclaw/src/routes/api/files/info.ts @@ -0,0 +1,67 @@ +import { createFileRoute } from '@tanstack/react-router' +import { json } from '@tanstack/react-start' +import { getFileInfo } from '../../../server/filesystem' +import { validatePath } from '../../../server/path-utils' +import type { FileItem } from '../../../lib/file-types' +import type { FileSystemError } from '../../../server/filesystem' + +type InfoResponse = FileItem + +type ErrorResponse = { + error: string + code: string +} + +export const Route = createFileRoute('/api/files/info')({ + server: { + handlers: { + GET: async ({ request }) => { + try { + const url = new URL(request.url) + const rawPath = url.searchParams.get('path') + + if (!rawPath) { + return json( + { + error: 'path parameter is required', + code: 'MISSING_PATH', + }, + { status: 400 } + ) + } + + // Validate and sanitize the path + const path = validatePath(rawPath, 'Path parameter') + + const fileInfo = await getFileInfo(path) + + return json(fileInfo) + } catch (err) { + const error = err as Error & FileSystemError + + if (error.message.includes('invalid characters') || + error.message.includes('traversal attempts')) { + return json( + { + error: error.message, + code: 'INVALID_PATH', + }, + { status: 400 }, + ) + } + + const status = error.status || 500 + const code = error.code || 'INTERNAL_ERROR' + + return json( + { + error: error.message || 'An unexpected error occurred', + code, + }, + { status }, + ) + } + }, + }, + }, +}) \ No newline at end of file diff --git a/apps/webclaw/src/routes/api/files/list.ts b/apps/webclaw/src/routes/api/files/list.ts new file mode 100644 index 0000000..ef9505e --- /dev/null +++ b/apps/webclaw/src/routes/api/files/list.ts @@ -0,0 +1,57 @@ +import { createFileRoute } from '@tanstack/react-router' +import { json } from '@tanstack/react-start' +import { listFiles } from '../../../server/filesystem' +import { validatePath } from '../../../server/path-utils' +import type { FileListing } from '../../../lib/file-types' +import type { FileSystemError } from '../../../server/filesystem' + +type ListResponse = FileListing + +type ErrorResponse = { + error: string + code: string +} + +export const Route = createFileRoute('/api/files/list')({ + server: { + handlers: { + GET: async ({ request }) => { + try { + const url = new URL(request.url) + const rawPath = url.searchParams.get('path') || '/' + + // Validate and sanitize the path + const path = validatePath(rawPath, 'Path parameter') + + const listing = await listFiles(path) + + return json(listing) + } catch (err) { + const error = err as Error & FileSystemError + + if (error.message.includes('invalid characters') || + error.message.includes('traversal attempts')) { + return json( + { + error: error.message, + code: 'INVALID_PATH', + }, + { status: 400 }, + ) + } + + const status = error.status || 500 + const code = error.code || 'INTERNAL_ERROR' + + return json( + { + error: error.message || 'An unexpected error occurred', + code, + }, + { status }, + ) + } + }, + }, + }, +}) \ No newline at end of file diff --git a/apps/webclaw/src/routes/api/files/mkdir.ts b/apps/webclaw/src/routes/api/files/mkdir.ts new file mode 100644 index 0000000..5917be4 --- /dev/null +++ b/apps/webclaw/src/routes/api/files/mkdir.ts @@ -0,0 +1,115 @@ +import { createFileRoute } from '@tanstack/react-router' +import { json } from '@tanstack/react-start' +import { createFolder } from '../../../server/filesystem' +import { validatePath } from '../../../server/path-utils' +import type { FileSystemError } from '../../../server/filesystem' + +type MkdirResponse = { + ok: true + message: string + path: string +} + +type ErrorResponse = { + error: string + code: string +} + +function validateFolderName(path: string): void { + // Extract folder name from path + const folderName = path.split('/').pop() || '' + + if (!folderName) { + throw new Error('Folder name cannot be empty') + } + + // Check for invalid characters in folder name + const invalidChars = /[<>:"|*?]/ + if (invalidChars.test(folderName)) { + throw new Error(`Folder name contains invalid characters: ${folderName}`) + } + + // Prevent creating system-like paths + const systemPaths = [ + 'bin', 'boot', 'dev', 'etc', 'lib', 'lib64', 'mnt', 'opt', 'proc', + 'root', 'run', 'sbin', 'srv', 'sys', 'tmp', 'usr', 'var', + 'System', 'Windows', 'Program Files', 'Program Files (x86)' + ] + + if (systemPaths.includes(folderName)) { + throw new Error(`Cannot create system directory: ${folderName}`) + } + + // Prevent names that are just dots or whitespace + if (folderName.trim() === '' || /^\.+$/.test(folderName)) { + throw new Error('Invalid folder name') + } +} + +export const Route = createFileRoute('/api/files/mkdir')({ + server: { + handlers: { + POST: async ({ request }) => { + try { + const body = (await request.json().catch(() => ({}))) as Record< + string, + unknown + > + + const rawPath = typeof body.path === 'string' ? body.path.trim() : '' + + if (!rawPath) { + return json( + { + error: 'path is required', + code: 'MISSING_PATH', + }, + { status: 400 } + ) + } + + // Validate and sanitize the path + const path = validatePath(rawPath, 'Path parameter') + + // Additional validation for folder names + validateFolderName(path) + + await createFolder(path) + + return json({ + ok: true, + message: 'Directory created successfully', + path, + }) + } catch (err) { + const error = err as Error & FileSystemError + + if (error.message.includes('invalid characters') || + error.message.includes('traversal attempts') || + error.message.includes('Invalid folder') || + error.message.includes('Cannot create system') || + error.message.includes('Folder name')) { + return json( + { + error: error.message, + code: 'VALIDATION_ERROR', + }, + { status: 400 }, + ) + } + + const status = error.status || 500 + const code = error.code || 'INTERNAL_ERROR' + + return json( + { + error: error.message || 'An unexpected error occurred', + code, + }, + { status }, + ) + } + }, + }, + }, +}) \ No newline at end of file diff --git a/apps/webclaw/src/routes/api/files/read.ts b/apps/webclaw/src/routes/api/files/read.ts new file mode 100644 index 0000000..566e47c --- /dev/null +++ b/apps/webclaw/src/routes/api/files/read.ts @@ -0,0 +1,81 @@ +import { createFileRoute } from '@tanstack/react-router' +import { downloadFile, getFileInfo } from '../../../server/filesystem' +import { validatePath } from '../../../server/path-utils' +import type { FileSystemError } from '../../../server/filesystem' + +type ErrorResponse = { + error: string + code: string +} + +const MAX_TEXT_SIZE = 5 * 1024 * 1024 // 5MB max for text editing + +const TEXT_EXTENSIONS = new Set([ + 'txt', 'md', 'log', 'json', 'xml', 'yaml', 'yml', 'csv', 'ini', 'conf', 'cfg', + 'js', 'ts', 'jsx', 'tsx', 'py', 'java', 'cpp', 'c', 'h', 'css', 'html', 'htm', + 'php', 'rb', 'go', 'rs', 'sh', 'bash', 'zsh', 'toml', 'env', 'gitignore', + 'dockerfile', 'makefile', 'sql', 'graphql', 'svelte', 'vue', 'scss', 'less', + 'diff', 'patch', +]) + +export const Route = createFileRoute('/api/files/read')({ + server: { + handlers: { + GET: async ({ request }) => { + try { + const url = new URL(request.url) + const rawPath = url.searchParams.get('path') + + if (!rawPath) { + return new Response( + JSON.stringify({ error: 'path parameter is required', code: 'MISSING_PATH' } as ErrorResponse), + { status: 400, headers: { 'Content-Type': 'application/json' } }, + ) + } + + const path = validatePath(rawPath, 'Path parameter') + const fileInfo = await getFileInfo(path) + + if (fileInfo.isDir) { + return new Response( + JSON.stringify({ error: 'Cannot read a directory', code: 'IS_DIRECTORY' } as ErrorResponse), + { status: 400, headers: { 'Content-Type': 'application/json' } }, + ) + } + + // Check extension + const ext = fileInfo.extension.toLowerCase() + if (!TEXT_EXTENSIONS.has(ext) && ext !== '') { + return new Response( + JSON.stringify({ error: 'File type not supported for text editing', code: 'UNSUPPORTED_TYPE' } as ErrorResponse), + { status: 400, headers: { 'Content-Type': 'application/json' } }, + ) + } + + if (fileInfo.size > MAX_TEXT_SIZE) { + return new Response( + JSON.stringify({ error: 'File too large for text editing (max 5MB)', code: 'FILE_TOO_LARGE' } as ErrorResponse), + { status: 413, headers: { 'Content-Type': 'application/json' } }, + ) + } + + const content = await downloadFile(path) + const text = new TextDecoder('utf-8', { fatal: false }).decode(new Uint8Array(content)) + + return new Response( + JSON.stringify({ content: text, name: fileInfo.name, extension: fileInfo.extension, size: fileInfo.size }), + { headers: { 'Content-Type': 'application/json' } }, + ) + } catch (err) { + const error = err as Error & FileSystemError + const status = error.status || 500 + const code = error.code || 'INTERNAL_ERROR' + return new Response( + JSON.stringify({ error: error.message || 'An unexpected error occurred', code } as ErrorResponse), + { status, headers: { 'Content-Type': 'application/json' } }, + ) + } + }, + }, + }, +}) diff --git a/apps/webclaw/src/routes/api/files/rename.ts b/apps/webclaw/src/routes/api/files/rename.ts new file mode 100644 index 0000000..834f42b --- /dev/null +++ b/apps/webclaw/src/routes/api/files/rename.ts @@ -0,0 +1,141 @@ +import { createFileRoute } from '@tanstack/react-router' +import { json } from '@tanstack/react-start' +import { renameFile, getFileInfo } from '../../../server/filesystem' +import { validatePath, validateFilename } from '../../../server/path-utils' +import type { FileSystemError } from '../../../server/filesystem' + +type RenameResponse = { + ok: true + message: string + src: string + dst: string +} + +type ErrorResponse = { + error: string + code: string +} + +export const Route = createFileRoute('/api/files/rename')({ + server: { + handlers: { + POST: async ({ request }) => { + try { + const body = (await request.json().catch(() => ({}))) as Record< + string, + unknown + > + + const rawSrc = typeof body.src === 'string' ? body.src.trim() : '' + const rawDst = typeof body.dst === 'string' ? body.dst.trim() : '' + + if (!rawSrc) { + return json( + { + error: 'src path is required', + code: 'MISSING_SRC_PATH', + }, + { status: 400 } + ) + } + + if (!rawDst) { + return json( + { + error: 'dst path is required', + code: 'MISSING_DST_PATH', + }, + { status: 400 } + ) + } + + // Validate and sanitize both paths + const src = validatePath(rawSrc, 'Source path') + const dst = validatePath(rawDst, 'Destination path') + + // Prevent renaming root + if (src === '/' || src === '') { + return json( + { + error: 'Cannot rename the root directory', + code: 'FORBIDDEN_RENAME_ROOT', + }, + { status: 403 } + ) + } + + // Check if it's just a filename (for validation) + const dstFilename = dst.split('/').pop() || '' + if (dstFilename && !validateFilename(dstFilename)) { + return json( + { + error: `Invalid destination filename: "${dstFilename}". Filenames cannot contain path separators or control characters`, + code: 'INVALID_FILENAME', + }, + { status: 400 } + ) + } + + // Check if destination already exists to prevent overwriting + let overwriteWarning = false + try { + await getFileInfo(dst) + overwriteWarning = true + } catch (err) { + // File doesn't exist, which is what we want for rename + } + + // Allow override with explicit query parameter + const url = new URL(request.url) + const allowOverwrite = url.searchParams.get('override') === 'true' + + if (overwriteWarning && !allowOverwrite) { + return json( + { + error: `Destination "${dst}" already exists. Use ?override=true to overwrite`, + code: 'DESTINATION_EXISTS', + }, + { status: 409 } // Conflict + ) + } + + await renameFile(src, dst) + + return json({ + ok: true, + message: overwriteWarning ? 'File renamed successfully (existing file overwritten)' : 'File renamed successfully', + src, + dst, + }) + } catch (err) { + const error = err as Error & FileSystemError + + if (error.message.includes('invalid characters') || + error.message.includes('traversal attempts') || + error.message.includes('Invalid destination') || + error.message.includes('Cannot rename') || + error.message.includes('already exists')) { + return json( + { + error: error.message, + code: 'VALIDATION_ERROR', + }, + { status: 400 }, + ) + } + + const status = error.status || 500 + const code = error.code || 'INTERNAL_ERROR' + + return json( + { + error: error.message || 'An unexpected error occurred', + code, + }, + { status }, + ) + } + }, + }, + }, +}) \ No newline at end of file diff --git a/apps/webclaw/src/routes/api/files/save.ts b/apps/webclaw/src/routes/api/files/save.ts new file mode 100644 index 0000000..eadbd35 --- /dev/null +++ b/apps/webclaw/src/routes/api/files/save.ts @@ -0,0 +1,69 @@ +import { createFileRoute } from '@tanstack/react-router' +import { json } from '@tanstack/react-start' +import { uploadFile } from '../../../server/filesystem' +import { validatePath } from '../../../server/path-utils' +import type { FileSystemError } from '../../../server/filesystem' + +type SaveResponse = { + ok: true + message: string + path: string + size: number +} + +type ErrorResponse = { + error: string + code: string +} + +const MAX_TEXT_SIZE = 5 * 1024 * 1024 // 5MB + +export const Route = createFileRoute('/api/files/save')({ + server: { + handlers: { + POST: async ({ request }) => { + try { + const body = (await request.json().catch(() => ({}))) as Record + + const rawPath = typeof body.path === 'string' ? body.path.trim() : '' + const content = typeof body.content === 'string' ? body.content : null + + if (!rawPath) { + return json({ error: 'path is required', code: 'MISSING_PATH' }, { status: 400 }) + } + + if (content === null) { + return json({ error: 'content is required', code: 'MISSING_CONTENT' }, { status: 400 }) + } + + const path = validatePath(rawPath, 'Path parameter') + + const encoded = new TextEncoder().encode(content) + if (encoded.byteLength > MAX_TEXT_SIZE) { + return json( + { error: 'Content too large (max 5MB)', code: 'CONTENT_TOO_LARGE' }, + { status: 413 }, + ) + } + + await uploadFile(path, encoded.buffer as ArrayBuffer) + + return json({ + ok: true, + message: 'File saved successfully', + path, + size: encoded.byteLength, + }) + } catch (err) { + const error = err as Error & FileSystemError + const status = error.status || 500 + const code = error.code || 'INTERNAL_ERROR' + return json( + { error: error.message || 'An unexpected error occurred', code }, + { status }, + ) + } + }, + }, + }, +}) diff --git a/apps/webclaw/src/routes/api/files/upload.ts b/apps/webclaw/src/routes/api/files/upload.ts new file mode 100644 index 0000000..93761d1 --- /dev/null +++ b/apps/webclaw/src/routes/api/files/upload.ts @@ -0,0 +1,141 @@ +import { createFileRoute } from '@tanstack/react-router' +import { json } from '@tanstack/react-start' +import { uploadFile } from '../../../server/filesystem' +import { validatePath, validateFilename } from '../../../server/path-utils' +import type { FileSystemError } from '../../../server/filesystem' + +const MAX_FILE_SIZE = 100 * 1024 * 1024 // 100MB + +type UploadedFileInfo = { + filename: string + size: number + path: string +} + +type UploadResponse = { + ok: true + message: string + files: UploadedFileInfo[] +} + +type ErrorResponse = { + error: string + code: string +} + +export const Route = createFileRoute('/api/files/upload')({ + server: { + handlers: { + POST: async ({ request }) => { + try { + const formData = await request.formData() + const rawPath = formData.get('path') as string | null + + if (!rawPath) { + return json( + { + error: 'path parameter is required', + code: 'MISSING_PATH', + }, + { status: 400 } + ) + } + + // Validate and sanitize the path + const path = validatePath(rawPath, 'Path parameter') + + // Get all files from FormData (supports multiple file uploads) + const files: File[] = [] + for (const [key, value] of formData.entries()) { + if (key === 'file' || key.startsWith('file')) { + if (value instanceof File) { + files.push(value) + } + } + } + + if (files.length === 0) { + return json( + { + error: 'At least one file is required', + code: 'NO_FILES', + }, + { status: 400 } + ) + } + + const uploadedFiles: UploadedFileInfo[] = [] + + for (const file of files) { + // Validate file size + if (file.size > MAX_FILE_SIZE) { + return json( + { + error: `File "${file.name}" is too large. Maximum size is ${MAX_FILE_SIZE / 1024 / 1024}MB`, + code: 'FILE_TOO_LARGE', + }, + { status: 413 } // Payload Too Large + ) + } + + // Validate filename + if (!validateFilename(file.name)) { + return json( + { + error: `Invalid filename: "${file.name}". Filenames cannot contain path separators or control characters`, + code: 'INVALID_FILENAME', + }, + { status: 400 } + ) + } + + // Convert file to ArrayBuffer + const content = await file.arrayBuffer() + + // Construct full file path + const fullPath = path.endsWith('/') ? `${path}${file.name}` : `${path}/${file.name}` + + await uploadFile(fullPath, content) + + uploadedFiles.push({ + filename: file.name, + size: content.byteLength, + path: fullPath, + }) + } + + return json({ + ok: true, + message: `${uploadedFiles.length} file(s) uploaded successfully`, + files: uploadedFiles, + }) + } catch (err) { + const error = err as Error & FileSystemError + + if (error.message.includes('invalid characters') || + error.message.includes('traversal attempts') || + error.message.includes('Invalid filename')) { + return json( + { + error: error.message, + code: 'VALIDATION_ERROR', + }, + { status: 400 }, + ) + } + + const status = error.status || 500 + const code = error.code || 'INTERNAL_ERROR' + + return json( + { + error: error.message || 'An unexpected error occurred', + code, + }, + { status }, + ) + } + }, + }, + }, +}) \ No newline at end of file diff --git a/apps/webclaw/src/routes/api/services.ts b/apps/webclaw/src/routes/api/services.ts new file mode 100644 index 0000000..46086b9 --- /dev/null +++ b/apps/webclaw/src/routes/api/services.ts @@ -0,0 +1,151 @@ +import { readFile, writeFile } from 'node:fs/promises' +import { resolve } from 'node:path' +import { createFileRoute } from '@tanstack/react-router' +import { json } from '@tanstack/react-start' + +type ServiceEntry = { + id: string + name: string + description: string + port: number + healthCheckUrl: string + repo: string + status: 'enabled' | 'disabled' +} + +type ServicesConfig = { + services: Array +} + +type ServiceWithHealth = ServiceEntry & { + healthy: boolean + healthError?: string +} + +const CONFIG_PATH = resolve( + import.meta.dirname ?? __dirname, + '../server/services-config.json', +) + +async function readConfig(): Promise { + try { + const raw = await readFile(CONFIG_PATH, 'utf-8') + return JSON.parse(raw) as ServicesConfig + } catch { + return { services: [] } + } +} + +async function writeConfig(config: ServicesConfig): Promise { + await writeFile(CONFIG_PATH, JSON.stringify(config, null, 2) + '\n', 'utf-8') +} + +async function checkHealth(url: string): Promise<{ healthy: boolean; error?: string }> { + try { + const controller = new AbortController() + const timeout = setTimeout(() => controller.abort(), 5000) + const res = await fetch(url, { signal: controller.signal }) + clearTimeout(timeout) + return { healthy: res.ok } + } catch (err) { + return { + healthy: false, + error: err instanceof Error ? err.message : String(err), + } + } +} + +export const Route = createFileRoute('/api/services')({ + server: { + handlers: { + GET: async () => { + try { + const config = await readConfig() + + const results: Array = await Promise.all( + config.services.map(async (svc) => { + if (!svc.healthCheckUrl) { + return { ...svc, healthy: false, healthError: 'no healthCheckUrl configured' } + } + const health = await checkHealth(svc.healthCheckUrl) + return { + ...svc, + healthy: health.healthy, + healthError: health.error, + } + }), + ) + + return json({ services: results }) + } catch (err) { + return json( + { error: err instanceof Error ? err.message : String(err) }, + { status: 500 }, + ) + } + }, + POST: async ({ request }) => { + try { + const body = (await request.json().catch(() => ({}))) as Record + + const name = typeof body.name === 'string' ? body.name.trim() : '' + if (!name) { + return json({ ok: false, error: 'name is required' }, { status: 400 }) + } + + const entry: ServiceEntry = { + id: typeof body.id === 'string' && body.id.trim() ? body.id.trim() : name.toLowerCase().replace(/\s+/g, '-'), + name, + description: typeof body.description === 'string' ? body.description : '', + port: typeof body.port === 'number' ? body.port : 0, + healthCheckUrl: typeof body.healthCheckUrl === 'string' ? body.healthCheckUrl : '', + repo: typeof body.repo === 'string' ? body.repo : '', + status: body.status === 'disabled' ? 'disabled' : 'enabled', + } + + const config = await readConfig() + const idx = config.services.findIndex((s) => s.id === entry.id) + if (idx >= 0) { + config.services[idx] = entry + } else { + config.services.push(entry) + } + + await writeConfig(config) + + return json({ ok: true, service: entry }) + } catch (err) { + return json( + { ok: false, error: err instanceof Error ? err.message : String(err) }, + { status: 500 }, + ) + } + }, + DELETE: async ({ request }) => { + try { + const url = new URL(request.url) + const id = url.searchParams.get('id') ?? '' + if (!id) { + return json({ ok: false, error: 'id is required' }, { status: 400 }) + } + + const config = await readConfig() + const idx = config.services.findIndex((s) => s.id === id) + if (idx < 0) { + return json({ ok: false, error: 'service not found' }, { status: 404 }) + } + + config.services.splice(idx, 1) + await writeConfig(config) + + return json({ ok: true }) + } catch (err) { + return json( + { ok: false, error: err instanceof Error ? err.message : String(err) }, + { status: 500 }, + ) + } + }, + }, + }, +}) diff --git a/apps/webclaw/src/routes/bots.tsx b/apps/webclaw/src/routes/bots.tsx new file mode 100644 index 0000000..91453d5 --- /dev/null +++ b/apps/webclaw/src/routes/bots.tsx @@ -0,0 +1,10 @@ +import { createFileRoute } from '@tanstack/react-router' +import { BotsScreen } from '../screens/bots/bots-screen' + +export const Route = createFileRoute('/bots')({ + component: BotsRoute, +}) + +function BotsRoute() { + return +} diff --git a/apps/webclaw/src/routes/files.tsx b/apps/webclaw/src/routes/files.tsx new file mode 100644 index 0000000..5e7bda9 --- /dev/null +++ b/apps/webclaw/src/routes/files.tsx @@ -0,0 +1,10 @@ +import { createFileRoute } from '@tanstack/react-router' +import { FileExplorerScreen } from '../screens/files/file-explorer-screen' + +export const Route = createFileRoute('/files')({ + component: FilesRoute, +}) + +function FilesRoute() { + return +} \ No newline at end of file diff --git a/apps/webclaw/src/routes/services.tsx b/apps/webclaw/src/routes/services.tsx new file mode 100644 index 0000000..b1b1986 --- /dev/null +++ b/apps/webclaw/src/routes/services.tsx @@ -0,0 +1,10 @@ +import { createFileRoute } from '@tanstack/react-router' +import { ServicesScreen } from '../screens/services/services-screen' + +export const Route = createFileRoute('/services')({ + component: ServicesRoute, +}) + +function ServicesRoute() { + return +} diff --git a/apps/webclaw/src/screens/chat/components/chat-sidebar.tsx b/apps/webclaw/src/screens/chat/components/chat-sidebar.tsx index 58f5d7a..98aa3c0 100644 --- a/apps/webclaw/src/screens/chat/components/chat-sidebar.tsx +++ b/apps/webclaw/src/screens/chat/components/chat-sidebar.tsx @@ -1,9 +1,12 @@ import { HugeiconsIcon } from '@hugeicons/react' import { + Folder01Icon, + ComputerIcon, PencilEdit02Icon, Search01Icon, Settings01Icon, SidebarLeft01Icon, + SmartPhone01Icon, } from '@hugeicons/core-free-icons' import { AnimatePresence, motion } from 'motion/react' import { memo, useState } from 'react' @@ -200,7 +203,7 @@ function ChatSidebarComponent({ + + + + + + + + {!isCollapsed && ( + + Files + + )} + + + + {isCollapsed && ( + + Files + + )} + + + + + + + + + + {!isCollapsed && ( + + Services + + )} + + + + {isCollapsed && ( + + Services + + )} + + + + + + + + + + {!isCollapsed && ( + + Bots + + )} + + + + {isCollapsed && ( + + Bots + + )} + + diff --git a/apps/webclaw/src/screens/files/components/create-folder-dialog.tsx b/apps/webclaw/src/screens/files/components/create-folder-dialog.tsx new file mode 100644 index 0000000..04d688e --- /dev/null +++ b/apps/webclaw/src/screens/files/components/create-folder-dialog.tsx @@ -0,0 +1,192 @@ +import { useState, useCallback } from 'react' +import { useCreateFolder } from '../hooks/use-files' +import { + DialogRoot, + DialogContent, + DialogTitle, + DialogDescription, + DialogClose, +} from '@/components/ui/dialog' +import { Button } from '@/components/ui/button' +import { cn } from '@/lib/utils' + +interface CreateFolderDialogProps { + open: boolean + onOpenChange: (open: boolean) => void + currentPath: string +} + +export function CreateFolderDialog({ open, onOpenChange, currentPath }: CreateFolderDialogProps) { + const [folderName, setFolderName] = useState('') + const [error, setError] = useState('') + const createFolderMutation = useCreateFolder() + + const handleCreate = useCallback(async () => { + if (!folderName.trim()) return + + setError('') + + try { + await createFolderMutation.mutateAsync({ + path: currentPath, + name: folderName.trim(), + }) + setFolderName('') + onOpenChange(false) + } catch (error) { + const errorMessage = error instanceof Error ? error.message : 'Failed to create folder' + setError(errorMessage) + console.error('Create folder failed:', error) + } + }, [folderName, currentPath, createFolderMutation, onOpenChange]) + + const handleOpenChange = useCallback((newOpen: boolean) => { + if (!newOpen) { + setFolderName('') + setError('') + } + onOpenChange(newOpen) + }, [onOpenChange]) + + const handleKeyDown = useCallback((e: React.KeyboardEvent) => { + if (e.key === 'Enter') { + e.preventDefault() + handleCreate() + } + }, [handleCreate]) + + const handleInputChange = useCallback((e: React.ChangeEvent) => { + const value = e.target.value + setFolderName(value) + + // Clear error when user starts typing + if (error) { + setError('') + } + }, [error]) + + // Validate folder name + const isValidName = folderName.trim().length > 0 && + !/[<>:"/\\|?*]/.test(folderName) && + !folderName.includes('..') + + const getButtonText = () => { + if (createFolderMutation.isPending) { + return 'Creating…' + } + return 'Create Folder' + } + + const getValidationError = () => { + if (!folderName.trim()) { + return '' + } + + if (folderName.includes('..')) { + return 'Folder name cannot contain ".."' + } + + if (/[<>:"/\\|?*]/.test(folderName)) { + return 'Invalid characters: < > : " / \\ | ? *' + } + + return '' + } + + const validationError = getValidationError() + const showError = error || validationError + + return ( + + +
+ Create New Folder + + Create a new folder in {currentPath === '/' ? 'root directory' : currentPath} + + +
+ + + {showError ? ( + + ) : null} +
+ + {/* Loading indicator */} + {createFolderMutation.isPending ? ( +
+

Creating folder…

+
+ ) : null} + +
+ + Cancel + + +
+ + {!isValidName && folderName.length > 0 ? ( +

+ Please enter a valid folder name +

+ ) : null} +
+
+
+ ) +} \ No newline at end of file diff --git a/apps/webclaw/src/screens/files/components/file-breadcrumb.tsx b/apps/webclaw/src/screens/files/components/file-breadcrumb.tsx new file mode 100644 index 0000000..cebd817 --- /dev/null +++ b/apps/webclaw/src/screens/files/components/file-breadcrumb.tsx @@ -0,0 +1,80 @@ +import { HugeiconsIcon } from '@hugeicons/react' +import { ArrowRight01Icon, Home02Icon } from '@hugeicons/core-free-icons' +import { useCallback } from 'react' +import { useFileExplorerState } from '../hooks/use-file-explorer-state' +import { cn } from '@/lib/utils' + +interface FileBreadcrumbProps { + path: string +} + +export function FileBreadcrumb({ path }: FileBreadcrumbProps) { + const { navigateTo } = useFileExplorerState() + + const segments = path === '/' ? [] : path.split('/').filter(Boolean) + const breadcrumbs = [ + { name: 'Home', path: '/' }, + ...segments.map((segment, index) => ({ + name: segment, + path: '/' + segments.slice(0, index + 1).join('/'), + })), + ] + + const handleNavigate = useCallback((breadcrumbPath: string) => { + navigateTo(breadcrumbPath) + }, [navigateTo]) + + const handleKeyDown = useCallback((e: React.KeyboardEvent, breadcrumbPath: string) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault() + navigateTo(breadcrumbPath) + } + }, [navigateTo]) + + return ( + + ) +} \ No newline at end of file diff --git a/apps/webclaw/src/screens/files/components/file-context-menu.tsx b/apps/webclaw/src/screens/files/components/file-context-menu.tsx new file mode 100644 index 0000000..b5f5f88 --- /dev/null +++ b/apps/webclaw/src/screens/files/components/file-context-menu.tsx @@ -0,0 +1,423 @@ +import { HugeiconsIcon } from '@hugeicons/react' +import { + Download04Icon, + Edit02Icon, + Delete02Icon, + FolderOpenIcon, + FileEditIcon, +} from '@hugeicons/core-free-icons' +import { useState, useCallback, useRef, useEffect } from 'react' +import { useFileDownload, useFileDelete, useFileRename } from '../hooks/use-files' +import { useFileExplorerState } from '../hooks/use-file-explorer-state' +import type { FileAction } from '../types' + +const EDITABLE_EXTENSIONS = new Set([ + 'txt', 'md', 'log', 'json', 'xml', 'yaml', 'yml', 'csv', 'ini', 'conf', 'cfg', + 'js', 'ts', 'jsx', 'tsx', 'py', 'java', 'cpp', 'c', 'h', 'css', 'html', 'htm', + 'php', 'rb', 'go', 'rs', 'sh', 'bash', 'zsh', 'toml', 'env', 'gitignore', + 'dockerfile', 'makefile', 'sql', 'graphql', 'svelte', 'vue', 'scss', 'less', +]) +import { + DialogRoot, + DialogContent, + DialogTitle, + DialogDescription, + DialogClose, +} from '@/components/ui/dialog' +import { Button } from '@/components/ui/button' +import { cn } from '@/lib/utils' +import type { FileItem } from '../types' + +interface FileContextMenuProps { + item: FileItem + children: React.ReactNode + onOpenFile?: (filePath: string) => void +} + +export function FileContextMenu({ item, children, onOpenFile }: FileContextMenuProps) { + const [menuOpen, setMenuOpen] = useState(false) + const [menuPosition, setMenuPosition] = useState({ x: 0, y: 0 }) + const [renameDialogOpen, setRenameDialogOpen] = useState(false) + const [deleteDialogOpen, setDeleteDialogOpen] = useState(false) + const [newName, setNewName] = useState(item.name) + const [renameError, setRenameError] = useState('') + const renameInputRef = useRef(null) + + const { navigateTo } = useFileExplorerState() + const downloadMutation = useFileDownload() + const deleteMutation = useFileDelete() + const renameMutation = useFileRename() + + const handleAction = useCallback((action: FileAction) => { + switch (action) { + case 'open': + if (item.isDir) { + navigateTo(item.path) + } + break + case 'edit': + if (onOpenFile) { + onOpenFile(item.path) + } + break + case 'download': + if (!item.isDir) { + downloadMutation.mutate(item.path) + } + break + case 'rename': + setNewName(item.name) + setRenameError('') + setRenameDialogOpen(true) + break + case 'delete': + setDeleteDialogOpen(true) + break + } + }, [item, downloadMutation, navigateTo]) + + const handleContextMenu = useCallback((e: React.MouseEvent) => { + e.preventDefault() + e.stopPropagation() + setMenuPosition({ x: e.clientX, y: e.clientY }) + setMenuOpen(true) + }, []) + + const confirmRename = useCallback(async () => { + if (!newName || newName === item.name) { + setRenameDialogOpen(false) + return + } + + setRenameError('') + + try { + await renameMutation.mutateAsync({ + oldPath: item.path, + newName: newName.trim(), + }) + setRenameDialogOpen(false) + } catch (error) { + const errorMessage = error instanceof Error ? error.message : 'Failed to rename' + setRenameError(errorMessage) + } + }, [newName, item, renameMutation]) + + const confirmDelete = useCallback(async () => { + try { + await deleteMutation.mutateAsync(item.path) + setDeleteDialogOpen(false) + } catch (error) { + console.error('Delete failed:', error) + // Error will be shown via the mutation's error handling + } + }, [item.path, deleteMutation]) + + const handleRenameKeyDown = useCallback((e: React.KeyboardEvent) => { + if (e.key === 'Enter') { + e.preventDefault() + confirmRename() + } else if (e.key === 'Escape') { + e.preventDefault() + setRenameDialogOpen(false) + } + }, [confirmRename]) + + const handleRenameInputChange = useCallback((e: React.ChangeEvent) => { + setNewName(e.target.value) + if (renameError) { + setRenameError('') + } + }, [renameError]) + + // Focus and select filename without extension when rename dialog opens + useEffect(() => { + if (renameDialogOpen && renameInputRef.current) { + const input = renameInputRef.current + input.focus() + + // Select filename without extension + if (!item.isDir && item.extension) { + const nameWithoutExt = item.name.replace(new RegExp(`\\.${item.extension}$`), '') + input.setSelectionRange(0, nameWithoutExt.length) + } else { + input.select() + } + } + }, [renameDialogOpen, item]) + + const isValidRename = newName.trim().length > 0 && + !/[<>:"/\\|?*]/.test(newName) && + !newName.includes('..') && + newName !== item.name + + const getDownloadText = () => { + return downloadMutation.isPending ? 'Downloading…' : 'Download' + } + + const getRenameText = () => { + return renameMutation.isPending ? 'Renaming…' : 'Rename' + } + + const getDeleteText = () => { + return deleteMutation.isPending ? 'Deleting…' : 'Delete' + } + + // Close menu on click outside or Escape + useEffect(() => { + if (!menuOpen) return + const handleClose = () => setMenuOpen(false) + const handleEsc = (e: KeyboardEvent) => { + if (e.key === 'Escape') setMenuOpen(false) + } + // Delay to avoid closing immediately from the same click + const timer = setTimeout(() => { + document.addEventListener('click', handleClose) + document.addEventListener('contextmenu', handleClose) + document.addEventListener('keydown', handleEsc) + }, 0) + return () => { + clearTimeout(timer) + document.removeEventListener('click', handleClose) + document.removeEventListener('contextmenu', handleClose) + document.removeEventListener('keydown', handleEsc) + } + }, [menuOpen]) + + const handleMenuItemClick = useCallback((action: FileAction) => { + setMenuOpen(false) + handleAction(action) + }, [handleAction]) + + return ( + <> +
+ {children} +
+ + {menuOpen ? ( +
+
e.stopPropagation()} + > + {item.isDir ? ( + + ) : null} + {!item.isDir ? ( + + ) : null} + {!item.isDir && EDITABLE_EXTENSIONS.has(item.extension.toLowerCase()) ? ( + + ) : null} + + + + +
+
+ ) : null} + + {/* Rename Dialog */} + + +
+ + Rename {item.isDir ? 'Folder' : 'File'} + + + Enter a new name for "{item.name}" + + +
+ + + {renameError ? ( + + ) : null} +
+ + {/* Loading indicator */} + {renameMutation.isPending ? ( +
+

Renaming…

+
+ ) : null} + +
+ + Cancel + + +
+
+
+
+ + {/* Delete Confirmation Dialog */} + + +
+ + Delete {item.isDir ? 'Folder' : 'File'} + + + Are you sure you want to delete "{item.name}"? + {item.isDir ? ' This will delete the folder and all its contents.' : ''} + {' '}This action cannot be undone. + + + {/* Loading indicator */} + {deleteMutation.isPending ? ( +
+

Deleting…

+
+ ) : null} + +
+ + Cancel + + +
+
+
+
+ + ) +} \ No newline at end of file diff --git a/apps/webclaw/src/screens/files/components/file-editor.tsx b/apps/webclaw/src/screens/files/components/file-editor.tsx new file mode 100644 index 0000000..5bd20b0 --- /dev/null +++ b/apps/webclaw/src/screens/files/components/file-editor.tsx @@ -0,0 +1,223 @@ +import { useState, useCallback, useEffect, useRef, useMemo } from 'react' +import { HugeiconsIcon } from '@hugeicons/react' +import { Cancel01Icon, FloppyDiskIcon } from '@hugeicons/core-free-icons' +import { useFileContent, useFileSave } from '../hooks/use-files' +import { Button } from '@/components/ui/button' +import { cn } from '@/lib/utils' + +interface FileEditorProps { + filePath: string + onClose: () => void +} + +const LANGUAGE_MAP: Record = { + js: 'JavaScript', + ts: 'TypeScript', + jsx: 'JSX', + tsx: 'TSX', + py: 'Python', + json: 'JSON', + yaml: 'YAML', + yml: 'YAML', + md: 'Markdown', + html: 'HTML', + css: 'CSS', + scss: 'SCSS', + sh: 'Shell', + bash: 'Bash', + sql: 'SQL', + xml: 'XML', + toml: 'TOML', + ini: 'INI', + conf: 'Config', + cfg: 'Config', + env: 'Env', + txt: 'Text', + log: 'Log', + csv: 'CSV', + go: 'Go', + rs: 'Rust', + rb: 'Ruby', + php: 'PHP', + java: 'Java', + c: 'C', + cpp: 'C++', + h: 'Header', + dockerfile: 'Dockerfile', + makefile: 'Makefile', + graphql: 'GraphQL', + svelte: 'Svelte', + vue: 'Vue', +} + +export function FileEditor({ filePath, onClose }: FileEditorProps) { + const contentQuery = useFileContent(filePath) + const saveMutation = useFileSave() + const textareaRef = useRef(null) + + const [editedContent, setEditedContent] = useState(null) + const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false) + + // Sync fetched content + useEffect(() => { + if (contentQuery.data && editedContent === null) { + setEditedContent(contentQuery.data.content) + } + }, [contentQuery.data, editedContent]) + + const language = useMemo(() => { + if (!contentQuery.data) return '' + return LANGUAGE_MAP[contentQuery.data.extension.toLowerCase()] || contentQuery.data.extension.toUpperCase() + }, [contentQuery.data]) + + const handleChange = useCallback((e: React.ChangeEvent) => { + setEditedContent(e.target.value) + setHasUnsavedChanges(e.target.value !== contentQuery.data?.content) + }, [contentQuery.data]) + + const handleSave = useCallback(async () => { + if (editedContent === null) return + try { + await saveMutation.mutateAsync({ path: filePath, content: editedContent }) + setHasUnsavedChanges(false) + } catch { + // Error shown via mutation state + } + }, [filePath, editedContent, saveMutation]) + + const handleClose = useCallback(() => { + if (hasUnsavedChanges) { + if (!window.confirm('You have unsaved changes. Discard them?')) return + } + onClose() + }, [hasUnsavedChanges, onClose]) + + // Ctrl+S to save + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + if ((e.ctrlKey || e.metaKey) && e.key === 's') { + e.preventDefault() + if (hasUnsavedChanges) handleSave() + } + if (e.key === 'Escape') { + handleClose() + } + } + window.addEventListener('keydown', handleKeyDown) + return () => window.removeEventListener('keydown', handleKeyDown) + }, [handleSave, handleClose, hasUnsavedChanges]) + + // Handle Tab key for indentation + const handleKeyDown = useCallback((e: React.KeyboardEvent) => { + if (e.key === 'Tab') { + e.preventDefault() + const textarea = e.currentTarget + const start = textarea.selectionStart + const end = textarea.selectionEnd + const value = textarea.value + + const newValue = value.substring(0, start) + ' ' + value.substring(end) + setEditedContent(newValue) + setHasUnsavedChanges(newValue !== contentQuery.data?.content) + + // Restore cursor position after React re-render + requestAnimationFrame(() => { + textarea.selectionStart = textarea.selectionEnd = start + 2 + }) + } + }, [contentQuery.data]) + + const fileName = filePath.split('/').pop() || filePath + + return ( +
+ {/* Toolbar */} +
+
+

{fileName}

+ {language ? ( + + {language} + + ) : null} + {hasUnsavedChanges ? ( + + Unsaved + + ) : null} + {saveMutation.isError ? ( + {saveMutation.error.message} + ) : null} +
+ +
+ + +
+
+ + {/* Editor area */} +
+ {contentQuery.isLoading ? ( +
+

Loading file…

+
+ ) : contentQuery.isError ? ( +
+
+

+ {contentQuery.error instanceof Error ? contentQuery.error.message : 'Failed to load file'} +

+ +
+
+ ) : ( +