mirror of
https://github.com/siteboon/claudecodeui.git
synced 2026-06-14 02:12:04 +08:00
Compare commits
17 Commits
v1.21.0
...
6be7045f17
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6be7045f17 | ||
|
|
d032a37c01 | ||
|
|
964d8e3231 | ||
|
|
84d4634735 | ||
|
|
14d17ae104 | ||
|
|
5d55e65727 | ||
|
|
909ff05118 | ||
|
|
b5bbf11524 | ||
|
|
855e22f917 | ||
|
|
97689588aa | ||
|
|
bc52d9ea28 | ||
|
|
503c384685 | ||
|
|
506d43144b | ||
|
|
9e22f42a3d | ||
|
|
9c0e864532 | ||
|
|
8339b8e624 | ||
|
|
061f0fd297 |
18
CHANGELOG.md
18
CHANGELOG.md
@@ -3,6 +3,24 @@
|
||||
All notable changes to CloudCLI UI will be documented in this file.
|
||||
|
||||
|
||||
## [1.22.0](https://github.com/siteboon/claudecodeui/compare/v1.21.0...v1.22.0) (2026-03-03)
|
||||
|
||||
### New Features
|
||||
|
||||
* add community button in the app ([84d4634](https://github.com/siteboon/claudecodeui/commit/84d4634735f9ee13ac1c20faa0e7e31f1b77cae8))
|
||||
* Advanced file editor and file tree improvements ([#444](https://github.com/siteboon/claudecodeui/issues/444)) ([9768958](https://github.com/siteboon/claudecodeui/commit/97689588aa2e8240ba4373da5f42ab444c772e72))
|
||||
* update document title based on selected project ([#448](https://github.com/siteboon/claudecodeui/issues/448)) ([9e22f42](https://github.com/siteboon/claudecodeui/commit/9e22f42a3d3a781f448ddac9d133292fe103bb8c))
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **claude:** correct project encoded path ([#451](https://github.com/siteboon/claudecodeui/issues/451)) ([9c0e864](https://github.com/siteboon/claudecodeui/commit/9c0e864532dcc5ce7ee890d3b4db722872db2b54)), closes [#447](https://github.com/siteboon/claudecodeui/issues/447)
|
||||
* **claude:** move model usage log to result message only ([#454](https://github.com/siteboon/claudecodeui/issues/454)) ([506d431](https://github.com/siteboon/claudecodeui/commit/506d43144b3ec3155c3e589e7e803862c4a8f83a))
|
||||
* missing translation label ([855e22f](https://github.com/siteboon/claudecodeui/commit/855e22f9176a71daa51de716370af7f19d55bfb4))
|
||||
|
||||
### Maintenance
|
||||
|
||||
* add Gemini-CLI support to README ([#453](https://github.com/siteboon/claudecodeui/issues/453)) ([503c384](https://github.com/siteboon/claudecodeui/commit/503c3846850fb843781979b0c0e10a24b07e1a4b))
|
||||
|
||||
## [1.21.0](https://github.com/siteboon/claudecodeui/compare/v1.20.1...v1.21.0) (2026-02-27)
|
||||
|
||||
### New Features
|
||||
|
||||
48
README.md
48
README.md
@@ -1,12 +1,20 @@
|
||||
<div align="center">
|
||||
<img src="public/logo.svg" alt="Claude Code UI" width="64" height="64">
|
||||
<img src="public/logo.svg" alt="CloudCLI UI" width="64" height="64">
|
||||
<h1>Cloud CLI (aka Claude Code UI)</h1>
|
||||
</div>
|
||||
|
||||
|
||||
A desktop and mobile UI for [Claude Code](https://docs.anthropic.com/en/docs/claude-code), [Cursor CLI](https://docs.cursor.com/en/cli/overview) and [Codex](https://developers.openai.com/codex). You can use it locally or remotely to view your active projects and sessions in Claude Code, Cursor, or Codex and make changes to them from everywhere (mobile or desktop). This gives you a proper interface that works everywhere.
|
||||
A desktop and mobile UI for [Claude Code](https://docs.anthropic.com/en/docs/claude-code), [Cursor CLI](https://docs.cursor.com/en/cli/overview), [Codex](https://developers.openai.com/codex), and [Gemini-CLI](https://geminicli.com/). You can use it locally or remotely to view your active projects and sessions and make changes to them from everywhere (mobile or desktop). This gives you a proper interface that works everywhere.
|
||||
|
||||
<p align="center">
|
||||
<a href="https://cloudcli.ai">CloudCLI Cloud</a> · <a href="https://discord.gg/buxwujPNRE">Discord</a> · <a href="https://github.com/siteboon/claudecodeui/issues">Bug Reports</a> · <a href="CONTRIBUTING.md">Contributing</a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://discord.gg/buxwujPNRE"><img src="https://img.shields.io/badge/Discord-Join%20Community-5865F2?logo=discord&logoColor=white" alt="Join our Discord"></a>
|
||||
<a href="https://trendshift.io/repositories/15586" target="_blank"><img src="https://trendshift.io/api/badge/repositories/15586" alt="siteboon%2Fclaudecodeui | Trendshift" style="width: 250px; height: 55px;" width="250" height="55"/></a>
|
||||
</p>
|
||||
|
||||
<a href="https://trendshift.io/repositories/15586" target="_blank"><img src="https://trendshift.io/api/badge/repositories/15586" alt="siteboon%2Fclaudecodeui | Trendshift" style="width: 250px; height: 55px;" width="250" height="55"/></a>
|
||||
<div align="right"><i><b>English</b> · <a href="./README.ko.md">한국어</a> · <a href="./README.zh-CN.md">中文</a> · <a href="./README.ja.md">日本語</a></i></div>
|
||||
|
||||
## Screenshots
|
||||
@@ -44,26 +52,35 @@ A desktop and mobile UI for [Claude Code](https://docs.anthropic.com/en/docs/cla
|
||||
|
||||
## Features
|
||||
|
||||
- **Responsive Design** - Works seamlessly across desktop, tablet, and mobile so you can also use Claude Code, Cursor, or Codex from mobile
|
||||
- **Interactive Chat Interface** - Built-in chat interface for seamless communication with Claude Code, Cursor, or Codex
|
||||
- **Integrated Shell Terminal** - Direct access to Claude Code, Cursor CLI, or Codex through built-in shell functionality
|
||||
- **Responsive Design** - Works seamlessly across desktop, tablet, and mobile so you can also use Agents from mobile
|
||||
- **Interactive Chat Interface** - Built-in chat interface for seamless communication with the Agents
|
||||
- **Integrated Shell Terminal** - Direct access to the Agents CLI through built-in shell functionality
|
||||
- **File Explorer** - Interactive file tree with syntax highlighting and live editing
|
||||
- **Git Explorer** - View, stage and commit your changes. You can also switch branches
|
||||
- **Session Management** - Resume conversations, manage multiple sessions, and track history
|
||||
- **TaskMaster AI Integration** *(Optional)* - Advanced project management with AI-powered task planning, PRD parsing, and workflow automation
|
||||
- **Model Compatibility** - Works with Claude Sonnet 4.5, Opus 4.5, and GPT-5.2
|
||||
- **Model Compatibility** - Works with Claude Sonnet 4.5, Opus 4.5, GPT-5.2, and Gemini.
|
||||
|
||||
|
||||
## Quick Start
|
||||
|
||||
### Prerequisites
|
||||
### CloudCLI Cloud (Recommended)
|
||||
|
||||
The fastest way to get started — no local setup required. Get a fully managed, containerized development environment accessible from the web, mobile app, API, or your favorite IDE.
|
||||
|
||||
**[Get started with CloudCLI Cloud](https://cloudcli.ai)**
|
||||
|
||||
### Self-Hosted (Open Source)
|
||||
|
||||
#### Prerequisites
|
||||
|
||||
- [Node.js](https://nodejs.org/) v22 or higher
|
||||
- [Claude Code CLI](https://docs.anthropic.com/en/docs/claude-code) installed and configured, and/or
|
||||
- [Cursor CLI](https://docs.cursor.com/en/cli/overview) installed and configured, and/or
|
||||
- [Codex](https://developers.openai.com/codex) installed and configured
|
||||
- [Codex](https://developers.openai.com/codex) installed and configured, and/or
|
||||
- [Gemini-CLI](https://geminicli.com/) installed and configured
|
||||
|
||||
### One-click Operation (Recommended)
|
||||
#### One-click Operation
|
||||
|
||||
No installation required, direct operation:
|
||||
|
||||
@@ -120,7 +137,7 @@ cloudcli status # Show current configuration
|
||||
|
||||
### Run as Background Service (Recommended for Production)
|
||||
|
||||
For production use, run Claude Code UI as a background service using PM2 (Process Manager 2):
|
||||
For production use, run CloudCLI as a background service using PM2 (Process Manager 2):
|
||||
|
||||
#### Install PM2
|
||||
|
||||
@@ -144,7 +161,7 @@ pm2 start cloudcli --name "claude-code-ui" -- --port 8080
|
||||
|
||||
#### Auto-Start on System Boot
|
||||
|
||||
To make Claude Code UI start automatically when your system boots:
|
||||
To make CloudCLI UI start automatically when your system boots:
|
||||
|
||||
```bash
|
||||
# Generate startup script for your platform
|
||||
@@ -208,7 +225,7 @@ To use Claude Code's full functionality, you'll need to manually enable tools:
|
||||
|
||||
## TaskMaster AI Integration *(Optional)*
|
||||
|
||||
Claude Code UI supports **[TaskMaster AI](https://github.com/eyaltoledano/claude-task-master)** (aka claude-task-master) integration for advanced project management and AI-powered task planning.
|
||||
CloudCLI UI supports **[TaskMaster AI](https://github.com/eyaltoledano/claude-task-master)** (aka claude-task-master) integration for advanced project management and AI-powered task planning.
|
||||
|
||||
It provides
|
||||
- AI-powered task generation from PRDs (Product Requirements Documents)
|
||||
@@ -279,7 +296,7 @@ session counts
|
||||
### Backend (Node.js + Express)
|
||||
- **Express Server** - RESTful API with static file serving
|
||||
- **WebSocket Server** - Communication for chats and project refresh
|
||||
- **Agent Integration (Claude Code / Cursor CLI / Codex)** - Process spawning and management
|
||||
- **Agent Integration (Claude Code / Cursor CLI / Codex / Gemini CLI)** - Process spawning and management
|
||||
- **File System API** - Exposing file browser for projects
|
||||
|
||||
### Frontend (React + Vite)
|
||||
@@ -327,6 +344,7 @@ This project is open source and free to use, modify, and distribute under the GP
|
||||
- **[Claude Code](https://docs.anthropic.com/en/docs/claude-code)** - Anthropic's official CLI
|
||||
- **[Cursor CLI](https://docs.cursor.com/en/cli/overview)** - Cursor's official CLI
|
||||
- **[Codex](https://developers.openai.com/codex)** - OpenAI Codex
|
||||
- **[Gemini-CLI](https://geminicli.com/)** - Google Gemini CLI
|
||||
- **[React](https://react.dev/)** - User interface library
|
||||
- **[Vite](https://vitejs.dev/)** - Fast build tool and dev server
|
||||
- **[Tailwind CSS](https://tailwindcss.com/)** - Utility-first CSS framework
|
||||
@@ -336,6 +354,8 @@ This project is open source and free to use, modify, and distribute under the GP
|
||||
## Support & Community
|
||||
|
||||
### Stay Updated
|
||||
- **[Join our Discord](https://discord.gg/buxwujPNRE)** - Get help, share feedback, and connect with the community
|
||||
- **[CloudCLI Cloud](https://cloudcli.ai)** - Try the hosted cloud version
|
||||
- **Star** this repository to show support
|
||||
- **Watch** for updates and new releases
|
||||
- **Follow** the project for announcements
|
||||
|
||||
193
package-lock.json
generated
193
package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "@siteboon/claude-code-ui",
|
||||
"version": "1.21.0",
|
||||
"version": "1.22.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@siteboon/claude-code-ui",
|
||||
"version": "1.21.0",
|
||||
"version": "1.22.0",
|
||||
"hasInstallScript": true,
|
||||
"license": "GPL-3.0",
|
||||
"dependencies": {
|
||||
@@ -43,6 +43,7 @@
|
||||
"i18next": "^25.7.4",
|
||||
"i18next-browser-languagedetector": "^8.2.0",
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
"jszip": "^3.10.1",
|
||||
"katex": "^0.16.25",
|
||||
"lucide-react": "^0.515.0",
|
||||
"mime-types": "^3.0.1",
|
||||
@@ -64,6 +65,7 @@
|
||||
"sqlite": "^5.1.1",
|
||||
"sqlite3": "^5.1.7",
|
||||
"tailwind-merge": "^3.3.1",
|
||||
"web-push": "^3.6.7",
|
||||
"ws": "^8.14.2"
|
||||
},
|
||||
"bin": {
|
||||
@@ -168,7 +170,6 @@
|
||||
"integrity": "sha512-UlLAnTPrFdNGoFtbSXwcGFQBtQZJCNjaN6hQNP3UPvuNXT1i82N26KL3dZeIpNalWywr9IuQuncaAfUaS1g6sQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@ampproject/remapping": "^2.2.0",
|
||||
"@babel/code-frame": "^7.27.1",
|
||||
@@ -541,7 +542,6 @@
|
||||
"resolved": "https://registry.npmjs.org/@codemirror/language/-/language-6.11.2.tgz",
|
||||
"integrity": "sha512-p44TsNArL4IVXDTbapUmEkAlvWs2CFQbcfc0ymDsis1kH2wh0gcY96AS29c/vp2d0y2Tquk1EDSaawpzilUiAw==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@codemirror/state": "^6.0.0",
|
||||
"@codemirror/view": "^6.23.0",
|
||||
@@ -556,7 +556,6 @@
|
||||
"resolved": "https://registry.npmjs.org/@codemirror/lint/-/lint-6.8.5.tgz",
|
||||
"integrity": "sha512-s3n3KisH7dx3vsoeGMxsbRAgKe4O1vbrnKBClm99PU0fWxmxsx5rR2PfqQgIt+2MMJBHbiJ5rfIdLYfB9NNvsA==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@codemirror/state": "^6.0.0",
|
||||
"@codemirror/view": "^6.35.0",
|
||||
@@ -592,7 +591,6 @@
|
||||
"resolved": "https://registry.npmjs.org/@codemirror/state/-/state-6.5.2.tgz",
|
||||
"integrity": "sha512-FVqsPqtPWKVVL3dPSxy8wEF/ymIEuVzF1PK3VbUgrxXpJUSHQWWZz4JMToquRxnkw+36LTamCZG2iua2Ptq0fA==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@marijn/find-cluster-break": "^1.0.0"
|
||||
}
|
||||
@@ -614,7 +612,6 @@
|
||||
"resolved": "https://registry.npmjs.org/@codemirror/view/-/view-6.38.1.tgz",
|
||||
"integrity": "sha512-RmTOkE7hRU3OVREqFVITWHz6ocgBjv08GoePscAakgVQfciA3SGCEk7mb9IzwW61cKKmlTpHXG6DUE5Ubx+MGQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@codemirror/state": "^6.5.0",
|
||||
"crelt": "^1.0.6",
|
||||
@@ -2037,8 +2034,7 @@
|
||||
"version": "1.2.3",
|
||||
"resolved": "https://registry.npmjs.org/@lezer/common/-/common-1.2.3.tgz",
|
||||
"integrity": "sha512-w7ojc8ejBqr2REPsWxJjrMFsA/ysDCFICn8zEOR9mrqzOu2amhITYuLD8ag6XZf0CFXDrhKqw7+tW8cX66NaDA==",
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@lezer/css": {
|
||||
"version": "1.3.0",
|
||||
@@ -2056,7 +2052,6 @@
|
||||
"resolved": "https://registry.npmjs.org/@lezer/highlight/-/highlight-1.2.1.tgz",
|
||||
"integrity": "sha512-Z5duk4RN/3zuVO7Jq0pGLJ3qynpxUVsh7IbUbGj88+uV2ApSAn6kWg2au3iJb+0Zi7kKtqffIESgNcRXWZWmSA==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@lezer/common": "^1.0.0"
|
||||
}
|
||||
@@ -2267,7 +2262,6 @@
|
||||
"resolved": "https://registry.npmjs.org/@octokit/core/-/core-7.0.4.tgz",
|
||||
"integrity": "sha512-jOT8V1Ba5BdC79sKrRWDdMT5l1R+XNHTPR6CPWzUP2EcfAcvIHZWF0eAbmRcpOOP5gVIwnqNg0C4nvh6Abc3OA==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@octokit/auth-token": "^6.0.0",
|
||||
"@octokit/graphql": "^9.0.1",
|
||||
@@ -3188,7 +3182,6 @@
|
||||
"resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.23.tgz",
|
||||
"integrity": "sha512-/LDXMQh55EzZQ0uVAZmKKhfENivEvWz6E+EYzh+/MCjMhNsotd+ZHhBGIjFDTi6+fz0OhQQQLbTgdQIxxCsC0w==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@types/prop-types": "*",
|
||||
"csstype": "^3.0.2"
|
||||
@@ -3333,8 +3326,7 @@
|
||||
"version": "5.5.0",
|
||||
"resolved": "https://registry.npmjs.org/@xterm/xterm/-/xterm-5.5.0.tgz",
|
||||
"integrity": "sha512-hqJHYaQb5OptNunnyAnkHyM8aCjZ1MEIDTQu1iIbbTD/xops91NB5yq1ZK/dC2JDbVWtF23zUtl9JE2NqwT87A==",
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/abbrev": {
|
||||
"version": "2.0.0",
|
||||
@@ -3384,7 +3376,6 @@
|
||||
"version": "7.1.4",
|
||||
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz",
|
||||
"integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 14"
|
||||
@@ -3519,6 +3510,18 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/asn1.js": {
|
||||
"version": "5.4.1",
|
||||
"resolved": "https://registry.npmjs.org/asn1.js/-/asn1.js-5.4.1.tgz",
|
||||
"integrity": "sha512-+I//4cYPccV8LdmBLiX8CYvf9Sp3vQsrqu2QNXRcrbiWvcx/UdlFiqUJJzxRQxgsZmvhXhn4cSKeSmoFjVdupA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"bn.js": "^4.0.0",
|
||||
"inherits": "^2.0.1",
|
||||
"minimalistic-assert": "^1.0.0",
|
||||
"safer-buffer": "^2.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/ast-types": {
|
||||
"version": "0.13.4",
|
||||
"resolved": "https://registry.npmjs.org/ast-types/-/ast-types-0.13.4.tgz",
|
||||
@@ -3755,6 +3758,12 @@
|
||||
"readable-stream": "^3.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/bn.js": {
|
||||
"version": "4.12.3",
|
||||
"resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.3.tgz",
|
||||
"integrity": "sha512-fGTi3gxV/23FTYdAoUtLYp6qySe2KE3teyZitipKNRuVYcBkoP/bB3guXN/XVKUe9mxCHXnc9C4ocyz8OmgN0g==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/body-parser": {
|
||||
"version": "1.20.3",
|
||||
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.3.tgz",
|
||||
@@ -3835,7 +3844,6 @@
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"caniuse-lite": "^1.0.30001726",
|
||||
"electron-to-chromium": "^1.5.173",
|
||||
@@ -4697,6 +4705,12 @@
|
||||
"integrity": "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/core-util-is": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz",
|
||||
"integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/cors": {
|
||||
"version": "2.8.5",
|
||||
"resolved": "https://registry.npmjs.org/cors/-/cors-2.8.5.tgz",
|
||||
@@ -6334,6 +6348,15 @@
|
||||
"url": "https://github.com/sponsors/wooorm"
|
||||
}
|
||||
},
|
||||
"node_modules/http_ece": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/http_ece/-/http_ece-1.2.0.tgz",
|
||||
"integrity": "sha512-JrF8SSLVmcvc5NducxgyOrKXe3EsyHMgBFgSaIUGmArKe+rwr0uphRkRXvwiom3I+fpIfoItveHrfudL8/rxuA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=16"
|
||||
}
|
||||
},
|
||||
"node_modules/http-cache-semantics": {
|
||||
"version": "4.2.0",
|
||||
"resolved": "https://registry.npmjs.org/http-cache-semantics/-/http-cache-semantics-4.2.0.tgz",
|
||||
@@ -6375,7 +6398,6 @@
|
||||
"version": "7.0.6",
|
||||
"resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz",
|
||||
"integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"agent-base": "^7.1.2",
|
||||
@@ -6424,7 +6446,6 @@
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.28.4"
|
||||
},
|
||||
@@ -6478,6 +6499,12 @@
|
||||
],
|
||||
"license": "BSD-3-Clause"
|
||||
},
|
||||
"node_modules/immediate": {
|
||||
"version": "3.0.6",
|
||||
"resolved": "https://registry.npmjs.org/immediate/-/immediate-3.0.6.tgz",
|
||||
"integrity": "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/import-cwd": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/import-cwd/-/import-cwd-3.0.0.tgz",
|
||||
@@ -6862,6 +6889,12 @@
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/isarray": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz",
|
||||
"integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/isexe": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
|
||||
@@ -6994,6 +7027,48 @@
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/jszip": {
|
||||
"version": "3.10.1",
|
||||
"resolved": "https://registry.npmjs.org/jszip/-/jszip-3.10.1.tgz",
|
||||
"integrity": "sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g==",
|
||||
"license": "(MIT OR GPL-3.0-or-later)",
|
||||
"dependencies": {
|
||||
"lie": "~3.3.0",
|
||||
"pako": "~1.0.2",
|
||||
"readable-stream": "~2.3.6",
|
||||
"setimmediate": "^1.0.5"
|
||||
}
|
||||
},
|
||||
"node_modules/jszip/node_modules/readable-stream": {
|
||||
"version": "2.3.8",
|
||||
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz",
|
||||
"integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"core-util-is": "~1.0.0",
|
||||
"inherits": "~2.0.3",
|
||||
"isarray": "~1.0.0",
|
||||
"process-nextick-args": "~2.0.0",
|
||||
"safe-buffer": "~5.1.1",
|
||||
"string_decoder": "~1.1.1",
|
||||
"util-deprecate": "~1.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/jszip/node_modules/safe-buffer": {
|
||||
"version": "5.1.2",
|
||||
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz",
|
||||
"integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/jszip/node_modules/string_decoder": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz",
|
||||
"integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"safe-buffer": "~5.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/jwa": {
|
||||
"version": "1.4.2",
|
||||
"resolved": "https://registry.npmjs.org/jwa/-/jwa-1.4.2.tgz",
|
||||
@@ -7049,6 +7124,15 @@
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/lie": {
|
||||
"version": "3.3.0",
|
||||
"resolved": "https://registry.npmjs.org/lie/-/lie-3.3.0.tgz",
|
||||
"integrity": "sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"immediate": "~3.0.5"
|
||||
}
|
||||
},
|
||||
"node_modules/lilconfig": {
|
||||
"version": "3.1.3",
|
||||
"resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz",
|
||||
@@ -8288,6 +8372,12 @@
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/minimalistic-assert": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/minimalistic-assert/-/minimalistic-assert-1.0.1.tgz",
|
||||
"integrity": "sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/minimatch": {
|
||||
"version": "9.0.5",
|
||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz",
|
||||
@@ -9118,6 +9208,12 @@
|
||||
"integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==",
|
||||
"license": "BlueOak-1.0.0"
|
||||
},
|
||||
"node_modules/pako": {
|
||||
"version": "1.0.11",
|
||||
"resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz",
|
||||
"integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==",
|
||||
"license": "(MIT AND Zlib)"
|
||||
},
|
||||
"node_modules/parse-entities": {
|
||||
"version": "4.0.2",
|
||||
"resolved": "https://registry.npmjs.org/parse-entities/-/parse-entities-4.0.2.tgz",
|
||||
@@ -9335,7 +9431,6 @@
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"nanoid": "^3.3.11",
|
||||
"picocolors": "^1.1.1",
|
||||
@@ -9518,6 +9613,12 @@
|
||||
"node": "^14.17.0 || ^16.13.0 || >=18.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/process-nextick-args": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz",
|
||||
"integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/promise-inflight": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/promise-inflight/-/promise-inflight-1.0.1.tgz",
|
||||
@@ -9717,7 +9818,6 @@
|
||||
"resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz",
|
||||
"integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"loose-envify": "^1.1.0"
|
||||
},
|
||||
@@ -9730,7 +9830,6 @@
|
||||
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz",
|
||||
"integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"loose-envify": "^1.1.0",
|
||||
"scheduler": "^0.23.2"
|
||||
@@ -10164,7 +10263,6 @@
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@nodeutils/defaults-deep": "1.1.0",
|
||||
"@octokit/rest": "22.0.0",
|
||||
@@ -10656,6 +10754,12 @@
|
||||
"license": "ISC",
|
||||
"optional": true
|
||||
},
|
||||
"node_modules/setimmediate": {
|
||||
"version": "1.0.5",
|
||||
"resolved": "https://registry.npmjs.org/setimmediate/-/setimmediate-1.0.5.tgz",
|
||||
"integrity": "sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/setprototypeof": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz",
|
||||
@@ -11900,7 +12004,6 @@
|
||||
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.17.tgz",
|
||||
"integrity": "sha512-w33E2aCvSDP0tW9RZuNXadXlkHXqFzSkQew/aIa2i/Sj8fThxwovwlXHSPXTbAHwEIhBFXAedUhP2tueAKP8Og==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@alloc/quick-lru": "^5.2.0",
|
||||
"arg": "^5.0.2",
|
||||
@@ -12144,7 +12247,6 @@
|
||||
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
@@ -12292,7 +12394,6 @@
|
||||
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
||||
"devOptional": true,
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"tsc": "bin/tsc",
|
||||
"tsserver": "bin/tsserver"
|
||||
@@ -12621,7 +12722,6 @@
|
||||
"integrity": "sha512-oBXvfSHEOL8jF+R9Am7h59Up07kVVGH1NrFGFoEG6bPDZP3tGpQhvkBpy5x7U6+E6wZCu9OihsWgJqDbQIm8LQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"esbuild": "^0.25.0",
|
||||
"fdir": "^6.5.0",
|
||||
@@ -12715,7 +12815,6 @@
|
||||
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
@@ -12758,6 +12857,46 @@
|
||||
"url": "https://github.com/sponsors/wooorm"
|
||||
}
|
||||
},
|
||||
"node_modules/web-push": {
|
||||
"version": "3.6.7",
|
||||
"resolved": "https://registry.npmjs.org/web-push/-/web-push-3.6.7.tgz",
|
||||
"integrity": "sha512-OpiIUe8cuGjrj3mMBFWY+e4MMIkW3SVT+7vEIjvD9kejGUypv8GPDf84JdPWskK8zMRIJ6xYGm+Kxr8YkPyA0A==",
|
||||
"license": "MPL-2.0",
|
||||
"dependencies": {
|
||||
"asn1.js": "^5.3.0",
|
||||
"http_ece": "1.2.0",
|
||||
"https-proxy-agent": "^7.0.0",
|
||||
"jws": "^4.0.0",
|
||||
"minimist": "^1.2.5"
|
||||
},
|
||||
"bin": {
|
||||
"web-push": "src/cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 16"
|
||||
}
|
||||
},
|
||||
"node_modules/web-push/node_modules/jwa": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz",
|
||||
"integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"buffer-equal-constant-time": "^1.0.1",
|
||||
"ecdsa-sig-formatter": "1.0.11",
|
||||
"safe-buffer": "^5.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/web-push/node_modules/jws": {
|
||||
"version": "4.0.1",
|
||||
"resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz",
|
||||
"integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"jwa": "^2.0.1",
|
||||
"safe-buffer": "^5.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/webidl-conversions": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@siteboon/claude-code-ui",
|
||||
"version": "1.21.0",
|
||||
"version": "1.22.0",
|
||||
"description": "A web-based UI for Claude Code CLI",
|
||||
"type": "module",
|
||||
"main": "server/index.js",
|
||||
@@ -78,6 +78,7 @@
|
||||
"i18next": "^25.7.4",
|
||||
"i18next-browser-languagedetector": "^8.2.0",
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
"jszip": "^3.10.1",
|
||||
"katex": "^0.16.25",
|
||||
"lucide-react": "^0.515.0",
|
||||
"mime-types": "^3.0.1",
|
||||
@@ -99,6 +100,7 @@
|
||||
"sqlite": "^5.1.1",
|
||||
"sqlite3": "^5.1.7",
|
||||
"tailwind-merge": "^3.3.1",
|
||||
"web-push": "^3.6.7",
|
||||
"ws": "^8.14.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
58
public/sw.js
58
public/sw.js
@@ -19,14 +19,17 @@ self.addEventListener('install', event => {
|
||||
|
||||
// Fetch event
|
||||
self.addEventListener('fetch', event => {
|
||||
// Never cache API requests or WebSocket upgrades
|
||||
if (event.request.url.includes('/api/') || event.request.url.includes('/ws')) {
|
||||
return;
|
||||
}
|
||||
|
||||
event.respondWith(
|
||||
caches.match(event.request)
|
||||
.then(response => {
|
||||
// Return cached response if found
|
||||
if (response) {
|
||||
return response;
|
||||
}
|
||||
// Otherwise fetch from network
|
||||
return fetch(event.request);
|
||||
}
|
||||
)
|
||||
@@ -46,4 +49,53 @@ self.addEventListener('activate', event => {
|
||||
);
|
||||
})
|
||||
);
|
||||
});
|
||||
self.clients.claim();
|
||||
});
|
||||
|
||||
// Push notification event
|
||||
self.addEventListener('push', event => {
|
||||
if (!event.data) return;
|
||||
|
||||
let payload;
|
||||
try {
|
||||
payload = event.data.json();
|
||||
} catch {
|
||||
payload = { title: 'Claude Code UI', body: event.data.text() };
|
||||
}
|
||||
|
||||
const options = {
|
||||
body: payload.body || '',
|
||||
icon: '/logo-256.png',
|
||||
badge: '/logo-128.png',
|
||||
data: payload.data || {},
|
||||
tag: payload.data?.code || 'default',
|
||||
renotify: true
|
||||
};
|
||||
|
||||
event.waitUntil(
|
||||
self.registration.showNotification(payload.title || 'Claude Code UI', options)
|
||||
);
|
||||
});
|
||||
|
||||
// Notification click event
|
||||
self.addEventListener('notificationclick', event => {
|
||||
event.notification.close();
|
||||
|
||||
const sessionId = event.notification.data?.sessionId;
|
||||
const urlPath = sessionId ? `/session/${sessionId}` : '/';
|
||||
|
||||
event.waitUntil(
|
||||
self.clients.matchAll({ type: 'window', includeUncontrolled: true }).then(clientList => {
|
||||
for (const client of clientList) {
|
||||
if (client.url.includes(self.location.origin)) {
|
||||
client.focus();
|
||||
if (sessionId) {
|
||||
client.navigate(self.location.origin + urlPath);
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
return self.clients.openWindow(urlPath);
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
@@ -18,6 +18,7 @@ import { promises as fs } from 'fs';
|
||||
import path from 'path';
|
||||
import os from 'os';
|
||||
import { CLAUDE_MODELS } from '../shared/modelConstants.js';
|
||||
import { createNotificationEvent, notifyUserIfEnabled } from './services/notification-orchestrator.js';
|
||||
|
||||
const activeSessions = new Map();
|
||||
const pendingToolApprovals = new Map();
|
||||
@@ -461,6 +462,14 @@ async function queryClaudeSDK(command, options = {}, ws) {
|
||||
let tempImagePaths = [];
|
||||
let tempDir = null;
|
||||
|
||||
const emitNotification = (event) => {
|
||||
notifyUserIfEnabled({
|
||||
userId: ws?.userId || null,
|
||||
writer: ws,
|
||||
event
|
||||
});
|
||||
};
|
||||
|
||||
try {
|
||||
// Map CLI options to SDK format
|
||||
const sdkOptions = mapCliOptionsToSDK(options);
|
||||
@@ -477,6 +486,42 @@ async function queryClaudeSDK(command, options = {}, ws) {
|
||||
tempImagePaths = imageResult.tempImagePaths;
|
||||
tempDir = imageResult.tempDir;
|
||||
|
||||
sdkOptions.hooks = {
|
||||
Notification: [{
|
||||
matcher: '',
|
||||
hooks: [async (input) => {
|
||||
const message = typeof input?.message === 'string' ? input.message : 'Claude requires your attention.';
|
||||
emitNotification(createNotificationEvent({
|
||||
provider: 'claude',
|
||||
sessionId: capturedSessionId || sessionId || null,
|
||||
kind: 'action_required',
|
||||
code: 'agent.notification',
|
||||
meta: { message },
|
||||
severity: 'warning',
|
||||
requiresUserAction: true,
|
||||
dedupeKey: `claude:hook:notification:${capturedSessionId || sessionId || 'none'}:${message}`
|
||||
}));
|
||||
return {};
|
||||
}]
|
||||
}],
|
||||
Stop: [{
|
||||
matcher: '',
|
||||
hooks: [async (input) => {
|
||||
const stopReason = typeof input?.stop_reason === 'string' ? input.stop_reason : 'completed';
|
||||
emitNotification(createNotificationEvent({
|
||||
provider: 'claude',
|
||||
sessionId: capturedSessionId || sessionId || null,
|
||||
kind: 'stop',
|
||||
code: 'run.stopped',
|
||||
meta: { stopReason },
|
||||
severity: 'info',
|
||||
dedupeKey: `claude:hook:stop:${capturedSessionId || sessionId || 'none'}:${stopReason}`
|
||||
}));
|
||||
return {};
|
||||
}]
|
||||
}]
|
||||
};
|
||||
|
||||
sdkOptions.canUseTool = async (toolName, input, context) => {
|
||||
const requiresInteraction = TOOLS_REQUIRING_INTERACTION.has(toolName);
|
||||
|
||||
@@ -508,6 +553,16 @@ async function queryClaudeSDK(command, options = {}, ws) {
|
||||
input,
|
||||
sessionId: capturedSessionId || sessionId || null
|
||||
});
|
||||
emitNotification(createNotificationEvent({
|
||||
provider: 'claude',
|
||||
sessionId: capturedSessionId || sessionId || null,
|
||||
kind: 'action_required',
|
||||
code: 'permission.required',
|
||||
meta: { toolName },
|
||||
severity: 'warning',
|
||||
requiresUserAction: true,
|
||||
dedupeKey: `claude:permission:${capturedSessionId || sessionId || 'none'}:${requestId}`
|
||||
}));
|
||||
|
||||
const decision = await waitForToolApproval(requestId, {
|
||||
timeoutMs: requiresInteraction ? 0 : undefined,
|
||||
@@ -548,10 +603,22 @@ async function queryClaudeSDK(command, options = {}, ws) {
|
||||
const prevStreamTimeout = process.env.CLAUDE_CODE_STREAM_CLOSE_TIMEOUT;
|
||||
process.env.CLAUDE_CODE_STREAM_CLOSE_TIMEOUT = '300000';
|
||||
|
||||
const queryInstance = query({
|
||||
prompt: finalCommand,
|
||||
options: sdkOptions
|
||||
});
|
||||
let queryInstance;
|
||||
try {
|
||||
queryInstance = query({
|
||||
prompt: finalCommand,
|
||||
options: sdkOptions
|
||||
});
|
||||
} catch (hookError) {
|
||||
// Older/newer SDK versions may not accept hook shapes yet.
|
||||
// Keep notification behavior operational via runtime events even if hook registration fails.
|
||||
console.warn('Failed to initialize Claude query with hooks, retrying without hooks:', hookError?.message || hookError);
|
||||
delete sdkOptions.hooks;
|
||||
queryInstance = query({
|
||||
prompt: finalCommand,
|
||||
options: sdkOptions
|
||||
});
|
||||
}
|
||||
|
||||
// Restore immediately — Query constructor already captured the value
|
||||
if (prevStreamTimeout !== undefined) {
|
||||
@@ -593,9 +660,6 @@ async function queryClaudeSDK(command, options = {}, ws) {
|
||||
console.log('No session_id in message or already captured. message.session_id:', message.session_id, 'capturedSessionId:', capturedSessionId);
|
||||
}
|
||||
|
||||
// logs which model was used in the message
|
||||
console.log("---> Model was sent using:", Object.keys(message.modelUsage || {}));
|
||||
|
||||
// Transform and send message to WebSocket
|
||||
const transformedMessage = transformMessage(message);
|
||||
ws.send({
|
||||
@@ -606,6 +670,10 @@ async function queryClaudeSDK(command, options = {}, ws) {
|
||||
|
||||
// Extract and send token budget updates from result messages
|
||||
if (message.type === 'result') {
|
||||
const models = Object.keys(message.modelUsage || {});
|
||||
if (models.length > 0) {
|
||||
console.log("---> Model was sent using:", models);
|
||||
}
|
||||
const tokenBudget = extractTokenBudget(message);
|
||||
if (tokenBudget) {
|
||||
console.log('Token budget from modelUsage:', tokenBudget);
|
||||
@@ -653,6 +721,15 @@ async function queryClaudeSDK(command, options = {}, ws) {
|
||||
error: error.message,
|
||||
sessionId: capturedSessionId || sessionId || null
|
||||
});
|
||||
emitNotification(createNotificationEvent({
|
||||
provider: 'claude',
|
||||
sessionId: capturedSessionId || sessionId || null,
|
||||
kind: 'error',
|
||||
code: 'run.failed',
|
||||
meta: { error: error.message },
|
||||
severity: 'error',
|
||||
dedupeKey: `claude:error:${capturedSessionId || sessionId || 'none'}:${error.message}`
|
||||
}));
|
||||
|
||||
throw error;
|
||||
}
|
||||
|
||||
@@ -91,6 +91,36 @@ const runMigrations = () => {
|
||||
db.exec('ALTER TABLE users ADD COLUMN has_completed_onboarding BOOLEAN DEFAULT 0');
|
||||
}
|
||||
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS user_notification_preferences (
|
||||
user_id INTEGER PRIMARY KEY,
|
||||
preferences_json TEXT NOT NULL,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||
)
|
||||
`);
|
||||
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS vapid_keys (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
public_key TEXT NOT NULL,
|
||||
private_key TEXT NOT NULL,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
`);
|
||||
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS push_subscriptions (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL,
|
||||
endpoint TEXT NOT NULL UNIQUE,
|
||||
keys_p256dh TEXT NOT NULL,
|
||||
keys_auth TEXT NOT NULL,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||
)
|
||||
`);
|
||||
|
||||
console.log('Database migrations completed successfully');
|
||||
} catch (error) {
|
||||
console.error('Error running migrations:', error.message);
|
||||
@@ -348,6 +378,116 @@ const credentialsDb = {
|
||||
}
|
||||
};
|
||||
|
||||
const DEFAULT_NOTIFICATION_PREFERENCES = {
|
||||
channels: {
|
||||
inApp: false,
|
||||
webPush: false
|
||||
},
|
||||
events: {
|
||||
actionRequired: true,
|
||||
stop: true,
|
||||
error: true
|
||||
}
|
||||
};
|
||||
|
||||
const normalizeNotificationPreferences = (value) => {
|
||||
const source = value && typeof value === 'object' ? value : {};
|
||||
|
||||
return {
|
||||
channels: {
|
||||
inApp: source.channels?.inApp === true,
|
||||
webPush: source.channels?.webPush === true
|
||||
},
|
||||
events: {
|
||||
actionRequired: source.events?.actionRequired !== false,
|
||||
stop: source.events?.stop !== false,
|
||||
error: source.events?.error !== false
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
const notificationPreferencesDb = {
|
||||
getPreferences: (userId) => {
|
||||
try {
|
||||
const row = db.prepare('SELECT preferences_json FROM user_notification_preferences WHERE user_id = ?').get(userId);
|
||||
if (!row) {
|
||||
const defaults = normalizeNotificationPreferences(DEFAULT_NOTIFICATION_PREFERENCES);
|
||||
db.prepare(
|
||||
'INSERT INTO user_notification_preferences (user_id, preferences_json, updated_at) VALUES (?, ?, CURRENT_TIMESTAMP)'
|
||||
).run(userId, JSON.stringify(defaults));
|
||||
return defaults;
|
||||
}
|
||||
|
||||
let parsed;
|
||||
try {
|
||||
parsed = JSON.parse(row.preferences_json);
|
||||
} catch {
|
||||
parsed = DEFAULT_NOTIFICATION_PREFERENCES;
|
||||
}
|
||||
return normalizeNotificationPreferences(parsed);
|
||||
} catch (err) {
|
||||
throw err;
|
||||
}
|
||||
},
|
||||
|
||||
updatePreferences: (userId, preferences) => {
|
||||
try {
|
||||
const normalized = normalizeNotificationPreferences(preferences);
|
||||
db.prepare(
|
||||
`INSERT INTO user_notification_preferences (user_id, preferences_json, updated_at)
|
||||
VALUES (?, ?, CURRENT_TIMESTAMP)
|
||||
ON CONFLICT(user_id) DO UPDATE SET
|
||||
preferences_json = excluded.preferences_json,
|
||||
updated_at = CURRENT_TIMESTAMP`
|
||||
).run(userId, JSON.stringify(normalized));
|
||||
return normalized;
|
||||
} catch (err) {
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const pushSubscriptionsDb = {
|
||||
saveSubscription: (userId, endpoint, keysP256dh, keysAuth) => {
|
||||
try {
|
||||
db.prepare(
|
||||
`INSERT INTO push_subscriptions (user_id, endpoint, keys_p256dh, keys_auth)
|
||||
VALUES (?, ?, ?, ?)
|
||||
ON CONFLICT(endpoint) DO UPDATE SET
|
||||
user_id = excluded.user_id,
|
||||
keys_p256dh = excluded.keys_p256dh,
|
||||
keys_auth = excluded.keys_auth`
|
||||
).run(userId, endpoint, keysP256dh, keysAuth);
|
||||
} catch (err) {
|
||||
throw err;
|
||||
}
|
||||
},
|
||||
|
||||
getSubscriptions: (userId) => {
|
||||
try {
|
||||
return db.prepare('SELECT endpoint, keys_p256dh, keys_auth FROM push_subscriptions WHERE user_id = ?').all(userId);
|
||||
} catch (err) {
|
||||
throw err;
|
||||
}
|
||||
},
|
||||
|
||||
removeSubscription: (endpoint) => {
|
||||
try {
|
||||
db.prepare('DELETE FROM push_subscriptions WHERE endpoint = ?').run(endpoint);
|
||||
} catch (err) {
|
||||
throw err;
|
||||
}
|
||||
},
|
||||
|
||||
removeAllForUser: (userId) => {
|
||||
try {
|
||||
db.prepare('DELETE FROM push_subscriptions WHERE user_id = ?').run(userId);
|
||||
} catch (err) {
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Backward compatibility - keep old names pointing to new system
|
||||
const githubTokensDb = {
|
||||
createGithubToken: (userId, tokenName, githubToken, description = null) => {
|
||||
@@ -373,5 +513,7 @@ export {
|
||||
userDb,
|
||||
apiKeysDb,
|
||||
credentialsDb,
|
||||
notificationPreferencesDb,
|
||||
pushSubscriptionsDb,
|
||||
githubTokensDb // Backward compatibility
|
||||
};
|
||||
};
|
||||
|
||||
@@ -49,4 +49,31 @@ CREATE TABLE IF NOT EXISTS user_credentials (
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_user_credentials_user_id ON user_credentials(user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_user_credentials_type ON user_credentials(credential_type);
|
||||
CREATE INDEX IF NOT EXISTS idx_user_credentials_active ON user_credentials(is_active);
|
||||
CREATE INDEX IF NOT EXISTS idx_user_credentials_active ON user_credentials(is_active);
|
||||
|
||||
-- User notification preferences (backend-owned, provider-agnostic)
|
||||
CREATE TABLE IF NOT EXISTS user_notification_preferences (
|
||||
user_id INTEGER PRIMARY KEY,
|
||||
preferences_json TEXT NOT NULL,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
-- VAPID key pair for Web Push notifications
|
||||
CREATE TABLE IF NOT EXISTS vapid_keys (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
public_key TEXT NOT NULL,
|
||||
private_key TEXT NOT NULL,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
-- Browser push subscriptions
|
||||
CREATE TABLE IF NOT EXISTS push_subscriptions (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL,
|
||||
endpoint TEXT NOT NULL UNIQUE,
|
||||
keys_p256dh TEXT NOT NULL,
|
||||
keys_auth TEXT NOT NULL,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
449
server/index.js
449
server/index.js
@@ -65,6 +65,7 @@ import userRoutes from './routes/user.js';
|
||||
import codexRoutes from './routes/codex.js';
|
||||
import geminiRoutes from './routes/gemini.js';
|
||||
import { initializeDatabase } from './database/db.js';
|
||||
import { configureWebPush } from './services/vapid-keys.js';
|
||||
import { validateApiKey, authenticateToken, authenticateWebSocket } from './middleware/auth.js';
|
||||
import { IS_PLATFORM } from './constants/config.js';
|
||||
|
||||
@@ -884,6 +885,436 @@ app.get('/api/projects/:projectName/files', authenticateToken, async (req, res)
|
||||
}
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// FILE OPERATIONS API ENDPOINTS
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Validate that a path is within the project root
|
||||
* @param {string} projectRoot - The project root path
|
||||
* @param {string} targetPath - The path to validate
|
||||
* @returns {{ valid: boolean, resolved?: string, error?: string }}
|
||||
*/
|
||||
function validatePathInProject(projectRoot, targetPath) {
|
||||
const resolved = path.isAbsolute(targetPath)
|
||||
? path.resolve(targetPath)
|
||||
: path.resolve(projectRoot, targetPath);
|
||||
const normalizedRoot = path.resolve(projectRoot) + path.sep;
|
||||
if (!resolved.startsWith(normalizedRoot)) {
|
||||
return { valid: false, error: 'Path must be under project root' };
|
||||
}
|
||||
return { valid: true, resolved };
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate filename - check for invalid characters
|
||||
* @param {string} name - The filename to validate
|
||||
* @returns {{ valid: boolean, error?: string }}
|
||||
*/
|
||||
function validateFilename(name) {
|
||||
if (!name || !name.trim()) {
|
||||
return { valid: false, error: 'Filename cannot be empty' };
|
||||
}
|
||||
// Check for invalid characters (Windows + Unix)
|
||||
const invalidChars = /[<>:"/\\|?*\x00-\x1f]/;
|
||||
if (invalidChars.test(name)) {
|
||||
return { valid: false, error: 'Filename contains invalid characters' };
|
||||
}
|
||||
// Check for reserved names (Windows)
|
||||
const reserved = /^(CON|PRN|AUX|NUL|COM[1-9]|LPT[1-9])$/i;
|
||||
if (reserved.test(name)) {
|
||||
return { valid: false, error: 'Filename is a reserved name' };
|
||||
}
|
||||
// Check for dots only
|
||||
if (/^\.+$/.test(name)) {
|
||||
return { valid: false, error: 'Filename cannot be only dots' };
|
||||
}
|
||||
return { valid: true };
|
||||
}
|
||||
|
||||
// POST /api/projects/:projectName/files/create - Create new file or directory
|
||||
app.post('/api/projects/:projectName/files/create', authenticateToken, async (req, res) => {
|
||||
try {
|
||||
const { projectName } = req.params;
|
||||
const { path: parentPath, type, name } = req.body;
|
||||
|
||||
// Validate input
|
||||
if (!name || !type) {
|
||||
return res.status(400).json({ error: 'Name and type are required' });
|
||||
}
|
||||
|
||||
if (!['file', 'directory'].includes(type)) {
|
||||
return res.status(400).json({ error: 'Type must be "file" or "directory"' });
|
||||
}
|
||||
|
||||
const nameValidation = validateFilename(name);
|
||||
if (!nameValidation.valid) {
|
||||
return res.status(400).json({ error: nameValidation.error });
|
||||
}
|
||||
|
||||
// Get project root
|
||||
const projectRoot = await extractProjectDirectory(projectName).catch(() => null);
|
||||
if (!projectRoot) {
|
||||
return res.status(404).json({ error: 'Project not found' });
|
||||
}
|
||||
|
||||
// Build and validate target path
|
||||
const targetDir = parentPath || '';
|
||||
const targetPath = targetDir ? path.join(targetDir, name) : name;
|
||||
const validation = validatePathInProject(projectRoot, targetPath);
|
||||
if (!validation.valid) {
|
||||
return res.status(403).json({ error: validation.error });
|
||||
}
|
||||
|
||||
const resolvedPath = validation.resolved;
|
||||
|
||||
// Check if already exists
|
||||
try {
|
||||
await fsPromises.access(resolvedPath);
|
||||
return res.status(409).json({ error: `${type === 'file' ? 'File' : 'Directory'} already exists` });
|
||||
} catch {
|
||||
// Doesn't exist, which is what we want
|
||||
}
|
||||
|
||||
// Create file or directory
|
||||
if (type === 'directory') {
|
||||
await fsPromises.mkdir(resolvedPath, { recursive: false });
|
||||
} else {
|
||||
// Ensure parent directory exists
|
||||
const parentDir = path.dirname(resolvedPath);
|
||||
try {
|
||||
await fsPromises.access(parentDir);
|
||||
} catch {
|
||||
await fsPromises.mkdir(parentDir, { recursive: true });
|
||||
}
|
||||
await fsPromises.writeFile(resolvedPath, '', 'utf8');
|
||||
}
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
path: resolvedPath,
|
||||
name,
|
||||
type,
|
||||
message: `${type === 'file' ? 'File' : 'Directory'} created successfully`
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error creating file/directory:', error);
|
||||
if (error.code === 'EACCES') {
|
||||
res.status(403).json({ error: 'Permission denied' });
|
||||
} else if (error.code === 'ENOENT') {
|
||||
res.status(404).json({ error: 'Parent directory not found' });
|
||||
} else {
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// PUT /api/projects/:projectName/files/rename - Rename file or directory
|
||||
app.put('/api/projects/:projectName/files/rename', authenticateToken, async (req, res) => {
|
||||
try {
|
||||
const { projectName } = req.params;
|
||||
const { oldPath, newName } = req.body;
|
||||
|
||||
// Validate input
|
||||
if (!oldPath || !newName) {
|
||||
return res.status(400).json({ error: 'oldPath and newName are required' });
|
||||
}
|
||||
|
||||
const nameValidation = validateFilename(newName);
|
||||
if (!nameValidation.valid) {
|
||||
return res.status(400).json({ error: nameValidation.error });
|
||||
}
|
||||
|
||||
// Get project root
|
||||
const projectRoot = await extractProjectDirectory(projectName).catch(() => null);
|
||||
if (!projectRoot) {
|
||||
return res.status(404).json({ error: 'Project not found' });
|
||||
}
|
||||
|
||||
// Validate old path
|
||||
const oldValidation = validatePathInProject(projectRoot, oldPath);
|
||||
if (!oldValidation.valid) {
|
||||
return res.status(403).json({ error: oldValidation.error });
|
||||
}
|
||||
|
||||
const resolvedOldPath = oldValidation.resolved;
|
||||
|
||||
// Check if old path exists
|
||||
try {
|
||||
await fsPromises.access(resolvedOldPath);
|
||||
} catch {
|
||||
return res.status(404).json({ error: 'File or directory not found' });
|
||||
}
|
||||
|
||||
// Build and validate new path
|
||||
const parentDir = path.dirname(resolvedOldPath);
|
||||
const resolvedNewPath = path.join(parentDir, newName);
|
||||
const newValidation = validatePathInProject(projectRoot, resolvedNewPath);
|
||||
if (!newValidation.valid) {
|
||||
return res.status(403).json({ error: newValidation.error });
|
||||
}
|
||||
|
||||
// Check if new path already exists
|
||||
try {
|
||||
await fsPromises.access(resolvedNewPath);
|
||||
return res.status(409).json({ error: 'A file or directory with this name already exists' });
|
||||
} catch {
|
||||
// Doesn't exist, which is what we want
|
||||
}
|
||||
|
||||
// Rename
|
||||
await fsPromises.rename(resolvedOldPath, resolvedNewPath);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
oldPath: resolvedOldPath,
|
||||
newPath: resolvedNewPath,
|
||||
newName,
|
||||
message: 'Renamed successfully'
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error renaming file/directory:', error);
|
||||
if (error.code === 'EACCES') {
|
||||
res.status(403).json({ error: 'Permission denied' });
|
||||
} else if (error.code === 'ENOENT') {
|
||||
res.status(404).json({ error: 'File or directory not found' });
|
||||
} else if (error.code === 'EXDEV') {
|
||||
res.status(400).json({ error: 'Cannot move across different filesystems' });
|
||||
} else {
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// DELETE /api/projects/:projectName/files - Delete file or directory
|
||||
app.delete('/api/projects/:projectName/files', authenticateToken, async (req, res) => {
|
||||
try {
|
||||
const { projectName } = req.params;
|
||||
const { path: targetPath, type } = req.body;
|
||||
|
||||
// Validate input
|
||||
if (!targetPath) {
|
||||
return res.status(400).json({ error: 'Path is required' });
|
||||
}
|
||||
|
||||
// Get project root
|
||||
const projectRoot = await extractProjectDirectory(projectName).catch(() => null);
|
||||
if (!projectRoot) {
|
||||
return res.status(404).json({ error: 'Project not found' });
|
||||
}
|
||||
|
||||
// Validate path
|
||||
const validation = validatePathInProject(projectRoot, targetPath);
|
||||
if (!validation.valid) {
|
||||
return res.status(403).json({ error: validation.error });
|
||||
}
|
||||
|
||||
const resolvedPath = validation.resolved;
|
||||
|
||||
// Check if path exists and get stats
|
||||
let stats;
|
||||
try {
|
||||
stats = await fsPromises.stat(resolvedPath);
|
||||
} catch {
|
||||
return res.status(404).json({ error: 'File or directory not found' });
|
||||
}
|
||||
|
||||
// Prevent deleting the project root itself
|
||||
if (resolvedPath === path.resolve(projectRoot)) {
|
||||
return res.status(403).json({ error: 'Cannot delete project root directory' });
|
||||
}
|
||||
|
||||
// Delete based on type
|
||||
if (stats.isDirectory()) {
|
||||
await fsPromises.rm(resolvedPath, { recursive: true, force: true });
|
||||
} else {
|
||||
await fsPromises.unlink(resolvedPath);
|
||||
}
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
path: resolvedPath,
|
||||
type: stats.isDirectory() ? 'directory' : 'file',
|
||||
message: 'Deleted successfully'
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error deleting file/directory:', error);
|
||||
if (error.code === 'EACCES') {
|
||||
res.status(403).json({ error: 'Permission denied' });
|
||||
} else if (error.code === 'ENOENT') {
|
||||
res.status(404).json({ error: 'File or directory not found' });
|
||||
} else if (error.code === 'ENOTEMPTY') {
|
||||
res.status(400).json({ error: 'Directory is not empty' });
|
||||
} else {
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// POST /api/projects/:projectName/files/upload - Upload files
|
||||
// Dynamic import of multer for file uploads
|
||||
const uploadFilesHandler = async (req, res) => {
|
||||
// Dynamic import of multer
|
||||
const multer = (await import('multer')).default;
|
||||
|
||||
const uploadMiddleware = multer({
|
||||
storage: multer.diskStorage({
|
||||
destination: (req, file, cb) => {
|
||||
cb(null, os.tmpdir());
|
||||
},
|
||||
filename: (req, file, cb) => {
|
||||
// Use a unique temp name, but preserve original name in file.originalname
|
||||
// Note: file.originalname may contain path separators for folder uploads
|
||||
const uniqueSuffix = Date.now() + '-' + Math.round(Math.random() * 1E9);
|
||||
// For temp file, just use a safe unique name without the path
|
||||
cb(null, `upload-${uniqueSuffix}`);
|
||||
}
|
||||
}),
|
||||
limits: {
|
||||
fileSize: 50 * 1024 * 1024, // 50MB limit
|
||||
files: 20 // Max 20 files at once
|
||||
}
|
||||
});
|
||||
|
||||
// Use multer middleware
|
||||
uploadMiddleware.array('files', 20)(req, res, async (err) => {
|
||||
if (err) {
|
||||
console.error('Multer error:', err);
|
||||
if (err.code === 'LIMIT_FILE_SIZE') {
|
||||
return res.status(400).json({ error: 'File too large. Maximum size is 50MB.' });
|
||||
}
|
||||
if (err.code === 'LIMIT_FILE_COUNT') {
|
||||
return res.status(400).json({ error: 'Too many files. Maximum is 20 files.' });
|
||||
}
|
||||
return res.status(500).json({ error: err.message });
|
||||
}
|
||||
|
||||
try {
|
||||
const { projectName } = req.params;
|
||||
const { targetPath, relativePaths } = req.body;
|
||||
|
||||
// Parse relative paths if provided (for folder uploads)
|
||||
let filePaths = [];
|
||||
if (relativePaths) {
|
||||
try {
|
||||
filePaths = JSON.parse(relativePaths);
|
||||
} catch (e) {
|
||||
console.log('[DEBUG] Failed to parse relativePaths:', relativePaths);
|
||||
}
|
||||
}
|
||||
|
||||
console.log('[DEBUG] File upload request:', {
|
||||
projectName,
|
||||
targetPath: JSON.stringify(targetPath),
|
||||
targetPathType: typeof targetPath,
|
||||
filesCount: req.files?.length,
|
||||
relativePaths: filePaths
|
||||
});
|
||||
|
||||
if (!req.files || req.files.length === 0) {
|
||||
return res.status(400).json({ error: 'No files provided' });
|
||||
}
|
||||
|
||||
// Get project root
|
||||
const projectRoot = await extractProjectDirectory(projectName).catch(() => null);
|
||||
if (!projectRoot) {
|
||||
return res.status(404).json({ error: 'Project not found' });
|
||||
}
|
||||
|
||||
console.log('[DEBUG] Project root:', projectRoot);
|
||||
|
||||
// Validate and resolve target path
|
||||
// If targetPath is empty or '.', use project root directly
|
||||
const targetDir = targetPath || '';
|
||||
let resolvedTargetDir;
|
||||
|
||||
console.log('[DEBUG] Target dir:', JSON.stringify(targetDir));
|
||||
|
||||
if (!targetDir || targetDir === '.' || targetDir === './') {
|
||||
// Empty path means upload to project root
|
||||
resolvedTargetDir = path.resolve(projectRoot);
|
||||
console.log('[DEBUG] Using project root as target:', resolvedTargetDir);
|
||||
} else {
|
||||
const validation = validatePathInProject(projectRoot, targetDir);
|
||||
if (!validation.valid) {
|
||||
console.log('[DEBUG] Path validation failed:', validation.error);
|
||||
return res.status(403).json({ error: validation.error });
|
||||
}
|
||||
resolvedTargetDir = validation.resolved;
|
||||
console.log('[DEBUG] Resolved target dir:', resolvedTargetDir);
|
||||
}
|
||||
|
||||
// Ensure target directory exists
|
||||
try {
|
||||
await fsPromises.access(resolvedTargetDir);
|
||||
} catch {
|
||||
await fsPromises.mkdir(resolvedTargetDir, { recursive: true });
|
||||
}
|
||||
|
||||
// Move uploaded files from temp to target directory
|
||||
const uploadedFiles = [];
|
||||
console.log('[DEBUG] Processing files:', req.files.map(f => ({ originalname: f.originalname, path: f.path })));
|
||||
for (let i = 0; i < req.files.length; i++) {
|
||||
const file = req.files[i];
|
||||
// Use relative path if provided (for folder uploads), otherwise use originalname
|
||||
const fileName = (filePaths && filePaths[i]) ? filePaths[i] : file.originalname;
|
||||
console.log('[DEBUG] Processing file:', fileName, '(originalname:', file.originalname + ')');
|
||||
const destPath = path.join(resolvedTargetDir, fileName);
|
||||
|
||||
// Validate destination path
|
||||
const destValidation = validatePathInProject(projectRoot, destPath);
|
||||
if (!destValidation.valid) {
|
||||
console.log('[DEBUG] Destination validation failed for:', destPath);
|
||||
// Clean up temp file
|
||||
await fsPromises.unlink(file.path).catch(() => {});
|
||||
continue;
|
||||
}
|
||||
|
||||
// Ensure parent directory exists (for nested files from folder upload)
|
||||
const parentDir = path.dirname(destPath);
|
||||
try {
|
||||
await fsPromises.access(parentDir);
|
||||
} catch {
|
||||
await fsPromises.mkdir(parentDir, { recursive: true });
|
||||
}
|
||||
|
||||
// Move file (copy + unlink to handle cross-device scenarios)
|
||||
await fsPromises.copyFile(file.path, destPath);
|
||||
await fsPromises.unlink(file.path);
|
||||
|
||||
uploadedFiles.push({
|
||||
name: fileName,
|
||||
path: destPath,
|
||||
size: file.size,
|
||||
mimeType: file.mimetype
|
||||
});
|
||||
}
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
files: uploadedFiles,
|
||||
targetPath: resolvedTargetDir,
|
||||
message: `Uploaded ${uploadedFiles.length} file(s) successfully`
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error uploading files:', error);
|
||||
// Clean up any remaining temp files
|
||||
if (req.files) {
|
||||
for (const file of req.files) {
|
||||
await fsPromises.unlink(file.path).catch(() => {});
|
||||
}
|
||||
}
|
||||
if (error.code === 'EACCES') {
|
||||
res.status(403).json({ error: 'Permission denied' });
|
||||
} else {
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
app.post('/api/projects/:projectName/files/upload', authenticateToken, uploadFilesHandler);
|
||||
|
||||
// WebSocket connection handler that routes based on URL path
|
||||
wss.on('connection', (ws, request) => {
|
||||
const url = request.url;
|
||||
@@ -896,7 +1327,7 @@ wss.on('connection', (ws, request) => {
|
||||
if (pathname === '/shell') {
|
||||
handleShellConnection(ws);
|
||||
} else if (pathname === '/ws') {
|
||||
handleChatConnection(ws);
|
||||
handleChatConnection(ws, request);
|
||||
} else {
|
||||
console.log('[WARN] Unknown WebSocket path:', pathname);
|
||||
ws.close();
|
||||
@@ -907,9 +1338,10 @@ wss.on('connection', (ws, request) => {
|
||||
* WebSocket Writer - Wrapper for WebSocket to match SSEStreamWriter interface
|
||||
*/
|
||||
class WebSocketWriter {
|
||||
constructor(ws) {
|
||||
constructor(ws, userId = null) {
|
||||
this.ws = ws;
|
||||
this.sessionId = null;
|
||||
this.userId = userId;
|
||||
this.isWebSocketWriter = true; // Marker for transport detection
|
||||
}
|
||||
|
||||
@@ -930,14 +1362,14 @@ class WebSocketWriter {
|
||||
}
|
||||
|
||||
// Handle chat WebSocket connections
|
||||
function handleChatConnection(ws) {
|
||||
function handleChatConnection(ws, request) {
|
||||
console.log('[INFO] Chat WebSocket connected');
|
||||
|
||||
// Add to connected clients for project updates
|
||||
connectedClients.add(ws);
|
||||
|
||||
// Wrap WebSocket with writer for consistent interface with SSEStreamWriter
|
||||
const writer = new WebSocketWriter(ws);
|
||||
const writer = new WebSocketWriter(ws, request?.user?.id ?? request?.user?.userId ?? null);
|
||||
|
||||
ws.on('message', async (message) => {
|
||||
try {
|
||||
@@ -1218,7 +1650,7 @@ function handleShellConnection(ws) {
|
||||
if (hasSession && sessionId) {
|
||||
try {
|
||||
// Gemini CLI enforces its own native session IDs, unlike other agents that accept arbitrary string names.
|
||||
// The UI only knows about its internal generated `sessionId` (e.g. gemini_1234).
|
||||
// The UI only knows about its internal generated `sessionId` (e.g. gemini_1234).
|
||||
// We must fetch the mapping from the backend session manager to pass the native `cliSessionId` to the shell.
|
||||
const sess = sessionManager.getSession(sessionId);
|
||||
if (sess && sess.cliSessionId) {
|
||||
@@ -1791,8 +2223,8 @@ app.get('/api/projects/:projectName/sessions/:sessionId/token-usage', authentica
|
||||
|
||||
// Construct the JSONL file path
|
||||
// Claude stores session files in ~/.claude/projects/[encoded-project-path]/[session-id].jsonl
|
||||
// The encoding replaces /, spaces, ~, and _ with -
|
||||
const encodedPath = projectPath.replace(/[\\/:\s~_]/g, '-');
|
||||
// The encoding replaces any non-alphanumeric character (except -) with -
|
||||
const encodedPath = projectPath.replace(/[^a-zA-Z0-9-]/g, '-');
|
||||
const projectDir = path.join(homeDir, '.claude', 'projects', encodedPath);
|
||||
|
||||
const jsonlPath = path.join(projectDir, `${safeSessionId}.jsonl`);
|
||||
@@ -1980,6 +2412,9 @@ async function startServer() {
|
||||
// Initialize authentication database
|
||||
await initializeDatabase();
|
||||
|
||||
// Configure Web Push (VAPID keys)
|
||||
configureWebPush();
|
||||
|
||||
// Check if running in production mode (dist folder exists)
|
||||
const distIndexPath = path.join(__dirname, '../dist/index.html');
|
||||
const isProduction = fs.existsSync(distIndexPath);
|
||||
|
||||
@@ -85,7 +85,7 @@ const authenticateWebSocket = (token) => {
|
||||
try {
|
||||
const user = userDb.getFirstUser();
|
||||
if (user) {
|
||||
return { userId: user.id, username: user.username };
|
||||
return { id: user.id, userId: user.id, username: user.username };
|
||||
}
|
||||
return null;
|
||||
} catch (error) {
|
||||
@@ -101,7 +101,10 @@ const authenticateWebSocket = (token) => {
|
||||
|
||||
try {
|
||||
const decoded = jwt.verify(token, JWT_SECRET);
|
||||
return decoded;
|
||||
return {
|
||||
...decoded,
|
||||
id: decoded.userId
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('WebSocket token verification error:', error);
|
||||
return null;
|
||||
@@ -114,4 +117,4 @@ export {
|
||||
generateToken,
|
||||
authenticateWebSocket,
|
||||
JWT_SECRET
|
||||
};
|
||||
};
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import express from 'express';
|
||||
import { apiKeysDb, credentialsDb } from '../database/db.js';
|
||||
import { apiKeysDb, credentialsDb, notificationPreferencesDb, pushSubscriptionsDb } from '../database/db.js';
|
||||
import { getPublicKey } from '../services/vapid-keys.js';
|
||||
import { createNotificationEvent, notifyUserIfEnabled } from '../services/notification-orchestrator.js';
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
@@ -175,4 +177,100 @@ router.patch('/credentials/:credentialId/toggle', async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
// ===============================
|
||||
// Notification Preferences
|
||||
// ===============================
|
||||
|
||||
router.get('/notification-preferences', async (req, res) => {
|
||||
try {
|
||||
const preferences = notificationPreferencesDb.getPreferences(req.user.id);
|
||||
res.json({ success: true, preferences });
|
||||
} catch (error) {
|
||||
console.error('Error fetching notification preferences:', error);
|
||||
res.status(500).json({ error: 'Failed to fetch notification preferences' });
|
||||
}
|
||||
});
|
||||
|
||||
router.put('/notification-preferences', async (req, res) => {
|
||||
try {
|
||||
const preferences = notificationPreferencesDb.updatePreferences(req.user.id, req.body || {});
|
||||
res.json({ success: true, preferences });
|
||||
} catch (error) {
|
||||
console.error('Error saving notification preferences:', error);
|
||||
res.status(500).json({ error: 'Failed to save notification preferences' });
|
||||
}
|
||||
});
|
||||
|
||||
// ===============================
|
||||
// Push Subscription Management
|
||||
// ===============================
|
||||
|
||||
router.get('/push/vapid-public-key', async (req, res) => {
|
||||
try {
|
||||
const publicKey = getPublicKey();
|
||||
res.json({ publicKey });
|
||||
} catch (error) {
|
||||
console.error('Error fetching VAPID public key:', error);
|
||||
res.status(500).json({ error: 'Failed to fetch VAPID public key' });
|
||||
}
|
||||
});
|
||||
|
||||
router.post('/push/subscribe', async (req, res) => {
|
||||
try {
|
||||
const { endpoint, keys } = req.body;
|
||||
if (!endpoint || !keys?.p256dh || !keys?.auth) {
|
||||
return res.status(400).json({ error: 'Missing subscription fields' });
|
||||
}
|
||||
pushSubscriptionsDb.saveSubscription(req.user.id, endpoint, keys.p256dh, keys.auth);
|
||||
|
||||
// Enable webPush in preferences so the confirmation goes through the full pipeline
|
||||
const currentPrefs = notificationPreferencesDb.getPreferences(req.user.id);
|
||||
if (!currentPrefs?.channels?.webPush) {
|
||||
notificationPreferencesDb.updatePreferences(req.user.id, {
|
||||
...currentPrefs,
|
||||
channels: { ...currentPrefs?.channels, webPush: true },
|
||||
});
|
||||
}
|
||||
|
||||
res.json({ success: true });
|
||||
|
||||
// Send a confirmation push through the full notification pipeline
|
||||
const event = createNotificationEvent({
|
||||
provider: 'system',
|
||||
kind: 'info',
|
||||
code: 'push.enabled',
|
||||
meta: { message: 'Push notifications are now enabled!' },
|
||||
severity: 'info'
|
||||
});
|
||||
notifyUserIfEnabled({ userId: req.user.id, event });
|
||||
} catch (error) {
|
||||
console.error('Error saving push subscription:', error);
|
||||
res.status(500).json({ error: 'Failed to save push subscription' });
|
||||
}
|
||||
});
|
||||
|
||||
router.post('/push/unsubscribe', async (req, res) => {
|
||||
try {
|
||||
const { endpoint } = req.body;
|
||||
if (!endpoint) {
|
||||
return res.status(400).json({ error: 'Missing endpoint' });
|
||||
}
|
||||
pushSubscriptionsDb.removeSubscription(endpoint);
|
||||
|
||||
// Disable webPush in preferences to match subscription state
|
||||
const currentPrefs = notificationPreferencesDb.getPreferences(req.user.id);
|
||||
if (currentPrefs?.channels?.webPush) {
|
||||
notificationPreferencesDb.updatePreferences(req.user.id, {
|
||||
...currentPrefs,
|
||||
channels: { ...currentPrefs.channels, webPush: false },
|
||||
});
|
||||
}
|
||||
|
||||
res.json({ success: true });
|
||||
} catch (error) {
|
||||
console.error('Error removing push subscription:', error);
|
||||
res.status(500).json({ error: 'Failed to remove push subscription' });
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
|
||||
137
server/services/notification-orchestrator.js
Normal file
137
server/services/notification-orchestrator.js
Normal file
@@ -0,0 +1,137 @@
|
||||
import webPush from 'web-push';
|
||||
import { notificationPreferencesDb, pushSubscriptionsDb } from '../database/db.js';
|
||||
|
||||
const KIND_TO_PREF_KEY = {
|
||||
action_required: 'actionRequired',
|
||||
stop: 'stop',
|
||||
error: 'error'
|
||||
};
|
||||
|
||||
const recentEventKeys = new Map();
|
||||
const DEDUPE_WINDOW_MS = 20000;
|
||||
|
||||
const cleanupOldEventKeys = () => {
|
||||
const now = Date.now();
|
||||
for (const [key, timestamp] of recentEventKeys.entries()) {
|
||||
if (now - timestamp > DEDUPE_WINDOW_MS) {
|
||||
recentEventKeys.delete(key);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
function shouldSendPush(preferences, event) {
|
||||
const webPushEnabled = Boolean(preferences?.channels?.webPush);
|
||||
const prefEventKey = KIND_TO_PREF_KEY[event.kind];
|
||||
const eventEnabled = prefEventKey ? Boolean(preferences?.events?.[prefEventKey]) : true;
|
||||
|
||||
return webPushEnabled && eventEnabled;
|
||||
}
|
||||
|
||||
function isDuplicate(event) {
|
||||
cleanupOldEventKeys();
|
||||
const key = event.dedupeKey || `${event.provider}:${event.kind || 'info'}:${event.code || 'generic'}:${event.sessionId || 'none'}`;
|
||||
if (recentEventKeys.has(key)) {
|
||||
return true;
|
||||
}
|
||||
recentEventKeys.set(key, Date.now());
|
||||
return false;
|
||||
}
|
||||
|
||||
function createNotificationEvent({
|
||||
provider,
|
||||
sessionId = null,
|
||||
kind = 'info',
|
||||
code = 'generic.info',
|
||||
meta = {},
|
||||
severity = 'info',
|
||||
dedupeKey = null,
|
||||
requiresUserAction = false
|
||||
}) {
|
||||
return {
|
||||
provider,
|
||||
sessionId,
|
||||
kind,
|
||||
code,
|
||||
meta,
|
||||
severity,
|
||||
requiresUserAction,
|
||||
dedupeKey,
|
||||
createdAt: new Date().toISOString()
|
||||
};
|
||||
}
|
||||
|
||||
function buildPushBody(event) {
|
||||
const CODE_MAP = {
|
||||
'permission.required': event.meta?.toolName
|
||||
? `Action Required: Tool "${event.meta.toolName}" needs approval`
|
||||
: 'Action Required: A tool needs your approval',
|
||||
'run.stopped': event.meta?.stopReason || 'Run Stopped: The run has stopped',
|
||||
'run.failed': event.meta?.error ? `Run Failed: ${event.meta.error}` : 'Run Failed: The run encountered an error',
|
||||
'agent.notification': event.meta?.message ? String(event.meta.message) : 'You have a new notification',
|
||||
'push.enabled': 'Push notifications are now enabled!'
|
||||
};
|
||||
|
||||
return {
|
||||
title: 'Claude Code UI',
|
||||
body: CODE_MAP[event.code] || 'You have a new notification',
|
||||
data: {
|
||||
sessionId: event.sessionId || null,
|
||||
code: event.code
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
async function sendWebPush(userId, event) {
|
||||
const subscriptions = pushSubscriptionsDb.getSubscriptions(userId);
|
||||
if (!subscriptions.length) return;
|
||||
|
||||
const payload = JSON.stringify(buildPushBody(event));
|
||||
|
||||
const results = await Promise.allSettled(
|
||||
subscriptions.map((sub) =>
|
||||
webPush.sendNotification(
|
||||
{
|
||||
endpoint: sub.endpoint,
|
||||
keys: {
|
||||
p256dh: sub.keys_p256dh,
|
||||
auth: sub.keys_auth
|
||||
}
|
||||
},
|
||||
payload
|
||||
)
|
||||
)
|
||||
);
|
||||
|
||||
// Clean up gone subscriptions (410 Gone or 404)
|
||||
results.forEach((result, index) => {
|
||||
if (result.status === 'rejected') {
|
||||
const statusCode = result.reason?.statusCode;
|
||||
if (statusCode === 410 || statusCode === 404) {
|
||||
pushSubscriptionsDb.removeSubscription(subscriptions[index].endpoint);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function notifyUserIfEnabled({ userId, event }) {
|
||||
if (!userId || !event) {
|
||||
return;
|
||||
}
|
||||
|
||||
const preferences = notificationPreferencesDb.getPreferences(userId);
|
||||
if (!shouldSendPush(preferences, event)) {
|
||||
return;
|
||||
}
|
||||
if (isDuplicate(event)) {
|
||||
return;
|
||||
}
|
||||
|
||||
sendWebPush(userId, event).catch((err) => {
|
||||
console.error('Web push send error:', err);
|
||||
});
|
||||
}
|
||||
|
||||
export {
|
||||
createNotificationEvent,
|
||||
notifyUserIfEnabled
|
||||
};
|
||||
35
server/services/vapid-keys.js
Normal file
35
server/services/vapid-keys.js
Normal file
@@ -0,0 +1,35 @@
|
||||
import webPush from 'web-push';
|
||||
import { db } from '../database/db.js';
|
||||
|
||||
let cachedKeys = null;
|
||||
|
||||
function ensureVapidKeys() {
|
||||
if (cachedKeys) return cachedKeys;
|
||||
|
||||
const row = db.prepare('SELECT public_key, private_key FROM vapid_keys ORDER BY id DESC LIMIT 1').get();
|
||||
if (row) {
|
||||
cachedKeys = { publicKey: row.public_key, privateKey: row.private_key };
|
||||
return cachedKeys;
|
||||
}
|
||||
|
||||
const keys = webPush.generateVAPIDKeys();
|
||||
db.prepare('INSERT INTO vapid_keys (public_key, private_key) VALUES (?, ?)').run(keys.publicKey, keys.privateKey);
|
||||
cachedKeys = keys;
|
||||
return cachedKeys;
|
||||
}
|
||||
|
||||
function getPublicKey() {
|
||||
return ensureVapidKeys().publicKey;
|
||||
}
|
||||
|
||||
function configureWebPush() {
|
||||
const keys = ensureVapidKeys();
|
||||
webPush.setVapidDetails(
|
||||
'mailto:noreply@claudecodeui.local',
|
||||
keys.publicKey,
|
||||
keys.privateKey
|
||||
);
|
||||
console.log('Web Push notifications configured');
|
||||
}
|
||||
|
||||
export { ensureVapidKeys, getPublicKey, configureWebPush };
|
||||
312
src/components/FileContextMenu.jsx
Normal file
312
src/components/FileContextMenu.jsx
Normal file
@@ -0,0 +1,312 @@
|
||||
import React, { useState, useEffect, useRef, useCallback } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
FileText,
|
||||
FolderPlus,
|
||||
Pencil,
|
||||
Trash2,
|
||||
Copy,
|
||||
Download,
|
||||
RefreshCw
|
||||
} from 'lucide-react';
|
||||
import { cn } from '../lib/utils';
|
||||
|
||||
/**
|
||||
* FileContextMenu Component
|
||||
* Right-click context menu for file/directory operations
|
||||
*/
|
||||
const FileContextMenu = ({
|
||||
children,
|
||||
item,
|
||||
onRename,
|
||||
onDelete,
|
||||
onNewFile,
|
||||
onNewFolder,
|
||||
onRefresh,
|
||||
onCopyPath,
|
||||
onDownload,
|
||||
isLoading = false,
|
||||
className = ''
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [position, setPosition] = useState({ x: 0, y: 0 });
|
||||
const menuRef = useRef(null);
|
||||
const triggerRef = useRef(null);
|
||||
|
||||
const isDirectory = item?.type === 'directory';
|
||||
const isFile = item?.type === 'file';
|
||||
const isBackground = !item; // Clicked on empty space
|
||||
|
||||
// Handle right-click
|
||||
const handleContextMenu = useCallback((e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
const rect = e.currentTarget.getBoundingClientRect();
|
||||
const x = e.clientX;
|
||||
const y = e.clientY;
|
||||
|
||||
// Adjust position if menu would go off screen
|
||||
const menuWidth = 200;
|
||||
const menuHeight = 300;
|
||||
|
||||
let adjustedX = x;
|
||||
let adjustedY = y;
|
||||
|
||||
if (x + menuWidth > window.innerWidth) {
|
||||
adjustedX = window.innerWidth - menuWidth - 10;
|
||||
}
|
||||
if (y + menuHeight > window.innerHeight) {
|
||||
adjustedY = window.innerHeight - menuHeight - 10;
|
||||
}
|
||||
|
||||
setPosition({ x: adjustedX, y: adjustedY });
|
||||
setIsOpen(true);
|
||||
}, []);
|
||||
|
||||
// Close menu
|
||||
const closeMenu = useCallback(() => {
|
||||
setIsOpen(false);
|
||||
}, []);
|
||||
|
||||
// Close on click outside
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (e) => {
|
||||
if (menuRef.current && !menuRef.current.contains(e.target)) {
|
||||
closeMenu();
|
||||
}
|
||||
};
|
||||
|
||||
const handleEscape = (e) => {
|
||||
if (e.key === 'Escape') {
|
||||
closeMenu();
|
||||
}
|
||||
};
|
||||
|
||||
if (isOpen) {
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
document.addEventListener('keydown', handleEscape);
|
||||
}
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', handleClickOutside);
|
||||
document.removeEventListener('keydown', handleEscape);
|
||||
};
|
||||
}, [isOpen, closeMenu]);
|
||||
|
||||
// Handle keyboard navigation
|
||||
useEffect(() => {
|
||||
if (!isOpen) return;
|
||||
|
||||
const handleKeyDown = (e) => {
|
||||
const menuItems = menuRef.current?.querySelectorAll('[role="menuitem"]');
|
||||
if (!menuItems || menuItems.length === 0) return;
|
||||
|
||||
const currentIndex = Array.from(menuItems).findIndex(
|
||||
(item) => item === document.activeElement
|
||||
);
|
||||
|
||||
switch (e.key) {
|
||||
case 'ArrowDown':
|
||||
e.preventDefault();
|
||||
const nextIndex = currentIndex < menuItems.length - 1 ? currentIndex + 1 : 0;
|
||||
menuItems[nextIndex]?.focus();
|
||||
break;
|
||||
case 'ArrowUp':
|
||||
e.preventDefault();
|
||||
const prevIndex = currentIndex > 0 ? currentIndex - 1 : menuItems.length - 1;
|
||||
menuItems[prevIndex]?.focus();
|
||||
break;
|
||||
case 'Enter':
|
||||
case ' ':
|
||||
if (document.activeElement?.hasAttribute('role', 'menuitem')) {
|
||||
e.preventDefault();
|
||||
document.activeElement.click();
|
||||
}
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('keydown', handleKeyDown);
|
||||
return () => document.removeEventListener('keydown', handleKeyDown);
|
||||
}, [isOpen]);
|
||||
|
||||
// Handle action click
|
||||
const handleAction = (action, ...args) => {
|
||||
closeMenu();
|
||||
action?.(...args);
|
||||
};
|
||||
|
||||
// Menu item component
|
||||
const MenuItem = ({ icon: Icon, label, onClick, danger = false, disabled = false, shortcut }) => (
|
||||
<button
|
||||
role="menuitem"
|
||||
tabIndex={disabled ? -1 : 0}
|
||||
disabled={disabled || isLoading}
|
||||
onClick={() => handleAction(onClick)}
|
||||
className={cn(
|
||||
'w-full flex items-center gap-3 px-3 py-2 text-sm text-left rounded-md transition-colors',
|
||||
'focus:outline-none focus:bg-accent',
|
||||
disabled
|
||||
? 'opacity-50 cursor-not-allowed'
|
||||
: danger
|
||||
? 'text-red-600 dark:text-red-400 hover:bg-red-50 dark:hover:bg-red-950'
|
||||
: 'hover:bg-accent',
|
||||
isLoading && 'pointer-events-none'
|
||||
)}
|
||||
>
|
||||
{Icon && <Icon className="w-4 h-4 flex-shrink-0" />}
|
||||
<span className="flex-1">{label}</span>
|
||||
{shortcut && (
|
||||
<span className="text-xs text-muted-foreground font-mono">{shortcut}</span>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
|
||||
// Menu divider
|
||||
const MenuDivider = () => (
|
||||
<div className="h-px bg-border my-1 mx-2" />
|
||||
);
|
||||
|
||||
// Build menu items based on context
|
||||
const renderMenuItems = () => {
|
||||
if (isFile) {
|
||||
return (
|
||||
<>
|
||||
<MenuItem
|
||||
icon={Pencil}
|
||||
label={t('fileTree.context.rename', 'Rename')}
|
||||
onClick={() => onRename?.(item)}
|
||||
/>
|
||||
<MenuItem
|
||||
icon={Trash2}
|
||||
label={t('fileTree.context.delete', 'Delete')}
|
||||
onClick={() => onDelete?.(item)}
|
||||
danger
|
||||
/>
|
||||
<MenuDivider />
|
||||
<MenuItem
|
||||
icon={Copy}
|
||||
label={t('fileTree.context.copyPath', 'Copy Path')}
|
||||
onClick={() => onCopyPath?.(item)}
|
||||
/>
|
||||
<MenuItem
|
||||
icon={Download}
|
||||
label={t('fileTree.context.download', 'Download')}
|
||||
onClick={() => onDownload?.(item)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
if (isDirectory) {
|
||||
return (
|
||||
<>
|
||||
<MenuItem
|
||||
icon={FileText}
|
||||
label={t('fileTree.context.newFile', 'New File')}
|
||||
onClick={() => onNewFile?.(item.path)}
|
||||
/>
|
||||
<MenuItem
|
||||
icon={FolderPlus}
|
||||
label={t('fileTree.context.newFolder', 'New Folder')}
|
||||
onClick={() => onNewFolder?.(item.path)}
|
||||
/>
|
||||
<MenuDivider />
|
||||
<MenuItem
|
||||
icon={Pencil}
|
||||
label={t('fileTree.context.rename', 'Rename')}
|
||||
onClick={() => onRename?.(item)}
|
||||
/>
|
||||
<MenuItem
|
||||
icon={Trash2}
|
||||
label={t('fileTree.context.delete', 'Delete')}
|
||||
onClick={() => onDelete?.(item)}
|
||||
danger
|
||||
/>
|
||||
<MenuDivider />
|
||||
<MenuItem
|
||||
icon={Copy}
|
||||
label={t('fileTree.context.copyPath', 'Copy Path')}
|
||||
onClick={() => onCopyPath?.(item)}
|
||||
/>
|
||||
<MenuItem
|
||||
icon={Download}
|
||||
label={t('fileTree.context.download', 'Download')}
|
||||
onClick={() => onDownload?.(item)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// Background context (empty space)
|
||||
return (
|
||||
<>
|
||||
<MenuItem
|
||||
icon={FileText}
|
||||
label={t('fileTree.context.newFile', 'New File')}
|
||||
onClick={() => onNewFile?.('')}
|
||||
/>
|
||||
<MenuItem
|
||||
icon={FolderPlus}
|
||||
label={t('fileTree.context.newFolder', 'New Folder')}
|
||||
onClick={() => onNewFolder?.('')}
|
||||
/>
|
||||
<MenuDivider />
|
||||
<MenuItem
|
||||
icon={RefreshCw}
|
||||
label={t('fileTree.context.refresh', 'Refresh')}
|
||||
onClick={onRefresh}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Trigger element */}
|
||||
<div
|
||||
ref={triggerRef}
|
||||
onContextMenu={handleContextMenu}
|
||||
className={cn('contents', className)}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
|
||||
{/* Context menu portal */}
|
||||
{isOpen && (
|
||||
<div
|
||||
ref={menuRef}
|
||||
role="menu"
|
||||
aria-label={t('fileTree.context.menuLabel', 'File context menu')}
|
||||
style={{
|
||||
position: 'fixed',
|
||||
left: position.x,
|
||||
top: position.y,
|
||||
zIndex: 9999
|
||||
}}
|
||||
className={cn(
|
||||
'min-w-[180px] py-1 px-1',
|
||||
'bg-popover border border-border rounded-lg shadow-lg',
|
||||
'animate-in fade-in-0 zoom-in-95',
|
||||
'data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95'
|
||||
)}
|
||||
>
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center py-4">
|
||||
<RefreshCw className="w-4 h-4 animate-spin text-muted-foreground" />
|
||||
<span className="ml-2 text-sm text-muted-foreground">
|
||||
{t('fileTree.context.loading', 'Loading...')}
|
||||
</span>
|
||||
</div>
|
||||
) : (
|
||||
renderMenuItems()
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default FileContextMenu;
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { api } from '../../../utils/api';
|
||||
import type { CodeEditorFile } from '../types/types';
|
||||
import { isBinaryFile } from '../utils/binaryFile';
|
||||
|
||||
type UseCodeEditorDocumentParams = {
|
||||
file: CodeEditorFile;
|
||||
@@ -21,6 +22,7 @@ export const useCodeEditorDocument = ({ file, projectPath }: UseCodeEditorDocume
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [saveSuccess, setSaveSuccess] = useState(false);
|
||||
const [saveError, setSaveError] = useState<string | null>(null);
|
||||
const [isBinary, setIsBinary] = useState(false);
|
||||
const fileProjectName = file.projectName ?? projectPath;
|
||||
const filePath = file.path;
|
||||
const fileName = file.name;
|
||||
@@ -31,6 +33,14 @@ export const useCodeEditorDocument = ({ file, projectPath }: UseCodeEditorDocume
|
||||
const loadFileContent = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
setIsBinary(false);
|
||||
|
||||
// Check if file is binary by extension
|
||||
if (isBinaryFile(file.name)) {
|
||||
setIsBinary(true);
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// Diff payload may already include full old/new snapshots, so avoid disk read.
|
||||
if (file.diffInfo && fileDiffNewString !== undefined && fileDiffOldString !== undefined) {
|
||||
@@ -60,7 +70,7 @@ export const useCodeEditorDocument = ({ file, projectPath }: UseCodeEditorDocume
|
||||
};
|
||||
|
||||
loadFileContent();
|
||||
}, [fileDiffNewString, fileDiffOldString, fileName, filePath, fileProjectName]);
|
||||
}, [file.diffInfo, file.name, fileDiffNewString, fileDiffOldString, fileName, filePath, fileProjectName]);
|
||||
|
||||
const handleSave = useCallback(async () => {
|
||||
setSaving(true);
|
||||
@@ -120,6 +130,7 @@ export const useCodeEditorDocument = ({ file, projectPath }: UseCodeEditorDocume
|
||||
saving,
|
||||
saveSuccess,
|
||||
saveError,
|
||||
isBinary,
|
||||
handleSave,
|
||||
handleDownload,
|
||||
};
|
||||
|
||||
@@ -65,12 +65,15 @@ export const useEditorSidebar = ({
|
||||
return;
|
||||
}
|
||||
|
||||
const container = resizeHandleRef.current?.parentElement;
|
||||
if (!container) {
|
||||
// Get the main container (parent of EditorSidebar's parent) that contains both left content and editor
|
||||
const editorContainer = resizeHandleRef.current?.parentElement;
|
||||
const mainContainer = editorContainer?.parentElement;
|
||||
if (!mainContainer) {
|
||||
return;
|
||||
}
|
||||
|
||||
const containerRect = container.getBoundingClientRect();
|
||||
const containerRect = mainContainer.getBoundingClientRect();
|
||||
// Calculate new editor width: distance from mouse to right edge of main container
|
||||
const newWidth = containerRect.right - event.clientX;
|
||||
|
||||
const minWidth = 300;
|
||||
|
||||
22
src/components/code-editor/utils/binaryFile.ts
Normal file
22
src/components/code-editor/utils/binaryFile.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
// Binary file extensions (images are handled by ImageViewer, not here)
|
||||
const BINARY_EXTENSIONS = [
|
||||
// Archives
|
||||
'zip', 'tar', 'gz', 'rar', '7z', 'bz2', 'xz',
|
||||
// Executables
|
||||
'exe', 'dll', 'so', 'dylib', 'app', 'dmg', 'msi',
|
||||
// Media
|
||||
'mp3', 'mp4', 'wav', 'avi', 'mov', 'mkv', 'flv', 'wmv', 'm4a', 'ogg',
|
||||
// Documents
|
||||
'pdf', 'doc', 'docx', 'xls', 'xlsx', 'ppt', 'pptx', 'odt', 'ods', 'odp',
|
||||
// Fonts
|
||||
'ttf', 'otf', 'woff', 'woff2', 'eot',
|
||||
// Database
|
||||
'db', 'sqlite', 'sqlite3',
|
||||
// Other binary
|
||||
'bin', 'dat', 'iso', 'img', 'class', 'jar', 'war', 'pyc', 'pyo'
|
||||
];
|
||||
|
||||
export const isBinaryFile = (filename: string): boolean => {
|
||||
const ext = filename.split('.').pop()?.toLowerCase();
|
||||
return BINARY_EXTENSIONS.includes(ext ?? '');
|
||||
};
|
||||
@@ -14,6 +14,7 @@ import CodeEditorFooter from './subcomponents/CodeEditorFooter';
|
||||
import CodeEditorHeader from './subcomponents/CodeEditorHeader';
|
||||
import CodeEditorLoadingState from './subcomponents/CodeEditorLoadingState';
|
||||
import CodeEditorSurface from './subcomponents/CodeEditorSurface';
|
||||
import CodeEditorBinaryFile from './subcomponents/CodeEditorBinaryFile';
|
||||
|
||||
type CodeEditorProps = {
|
||||
file: CodeEditorFile;
|
||||
@@ -54,6 +55,7 @@ export default function CodeEditor({
|
||||
saving,
|
||||
saveSuccess,
|
||||
saveError,
|
||||
isBinary,
|
||||
handleSave,
|
||||
handleDownload,
|
||||
} = useCodeEditorDocument({
|
||||
@@ -158,6 +160,21 @@ export default function CodeEditor({
|
||||
);
|
||||
}
|
||||
|
||||
// Binary file display
|
||||
if (isBinary) {
|
||||
return (
|
||||
<CodeEditorBinaryFile
|
||||
file={file}
|
||||
isSidebar={isSidebar}
|
||||
isFullscreen={isFullscreen}
|
||||
onClose={onClose}
|
||||
onToggleFullscreen={() => setIsFullscreen((previous) => !previous)}
|
||||
title={t('binaryFile.title', 'Binary File')}
|
||||
message={t('binaryFile.message', 'The file "{{fileName}}" cannot be displayed in the text editor because it is a binary file.', { fileName: file.name })}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const outerContainerClassName = isSidebar
|
||||
? 'w-full h-full flex flex-col'
|
||||
: `fixed inset-0 z-[9999] md:bg-black/50 md:flex md:items-center md:justify-center md:p-4 ${isFullscreen ? 'md:p-0' : ''}`;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState } from 'react';
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import type { MouseEvent, MutableRefObject } from 'react';
|
||||
import type { CodeEditorFile } from '../types/types';
|
||||
import CodeEditor from './CodeEditor';
|
||||
@@ -17,6 +17,11 @@ type EditorSidebarProps = {
|
||||
fillSpace?: boolean;
|
||||
};
|
||||
|
||||
// Minimum width for the left content (file tree, chat, etc.)
|
||||
const MIN_LEFT_CONTENT_WIDTH = 200;
|
||||
// Minimum width for the editor sidebar
|
||||
const MIN_EDITOR_WIDTH = 280;
|
||||
|
||||
export default function EditorSidebar({
|
||||
editingFile,
|
||||
isMobile,
|
||||
@@ -31,6 +36,49 @@ export default function EditorSidebar({
|
||||
fillSpace,
|
||||
}: EditorSidebarProps) {
|
||||
const [poppedOut, setPoppedOut] = useState(false);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const [effectiveWidth, setEffectiveWidth] = useState(editorWidth);
|
||||
|
||||
// Adjust editor width when container size changes to ensure buttons are always visible
|
||||
useEffect(() => {
|
||||
if (!editingFile || isMobile || poppedOut) return;
|
||||
|
||||
const updateWidth = () => {
|
||||
if (!containerRef.current) return;
|
||||
const parentElement = containerRef.current.parentElement;
|
||||
if (!parentElement) return;
|
||||
|
||||
const containerWidth = parentElement.clientWidth;
|
||||
|
||||
// Calculate maximum allowed editor width
|
||||
const maxEditorWidth = containerWidth - MIN_LEFT_CONTENT_WIDTH;
|
||||
|
||||
if (maxEditorWidth < MIN_EDITOR_WIDTH) {
|
||||
// Not enough space - pop out the editor so user can still see everything
|
||||
setPoppedOut(true);
|
||||
} else if (editorWidth > maxEditorWidth) {
|
||||
// Editor is too wide - constrain it to ensure left content has space
|
||||
setEffectiveWidth(maxEditorWidth);
|
||||
} else {
|
||||
setEffectiveWidth(editorWidth);
|
||||
}
|
||||
};
|
||||
|
||||
updateWidth();
|
||||
window.addEventListener('resize', updateWidth);
|
||||
|
||||
// Also use ResizeObserver for more accurate detection
|
||||
const resizeObserver = new ResizeObserver(updateWidth);
|
||||
const parentEl = containerRef.current?.parentElement;
|
||||
if (parentEl) {
|
||||
resizeObserver.observe(parentEl);
|
||||
}
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('resize', updateWidth);
|
||||
resizeObserver.disconnect();
|
||||
};
|
||||
}, [editingFile, isMobile, poppedOut, editorWidth]);
|
||||
|
||||
if (!editingFile) {
|
||||
return null;
|
||||
@@ -54,7 +102,7 @@ export default function EditorSidebar({
|
||||
const useFlexLayout = editorExpanded || (fillSpace && !hasManualWidth);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div ref={containerRef} className={`flex h-full flex-shrink-0 min-w-0 ${editorExpanded ? 'flex-1' : ''}`}>
|
||||
{!editorExpanded && (
|
||||
<div
|
||||
ref={resizeHandleRef}
|
||||
@@ -67,8 +115,8 @@ export default function EditorSidebar({
|
||||
)}
|
||||
|
||||
<div
|
||||
className={`flex-shrink-0 border-l border-gray-200 dark:border-gray-700 h-full overflow-hidden ${useFlexLayout ? 'flex-1' : ''}`}
|
||||
style={useFlexLayout ? undefined : { width: `${editorWidth}px` }}
|
||||
className={`border-l border-gray-200 dark:border-gray-700 h-full overflow-hidden ${useFlexLayout ? 'flex-1 min-w-0' : `flex-shrink-0 min-w-[${MIN_EDITOR_WIDTH}px]`}`}
|
||||
style={useFlexLayout ? undefined : { width: `${effectiveWidth}px`, minWidth: `${MIN_EDITOR_WIDTH}px` }}
|
||||
>
|
||||
<CodeEditor
|
||||
file={editingFile}
|
||||
@@ -80,6 +128,6 @@ export default function EditorSidebar({
|
||||
onPopOut={() => setPoppedOut(true)}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
import type { CodeEditorFile } from '../../types/types';
|
||||
|
||||
type CodeEditorBinaryFileProps = {
|
||||
file: CodeEditorFile;
|
||||
isSidebar: boolean;
|
||||
isFullscreen: boolean;
|
||||
onClose: () => void;
|
||||
onToggleFullscreen: () => void;
|
||||
title: string;
|
||||
message: string;
|
||||
};
|
||||
|
||||
export default function CodeEditorBinaryFile({
|
||||
file,
|
||||
isSidebar,
|
||||
isFullscreen,
|
||||
onClose,
|
||||
onToggleFullscreen,
|
||||
title,
|
||||
message,
|
||||
}: CodeEditorBinaryFileProps) {
|
||||
const binaryContent = (
|
||||
<div className="w-full h-full flex flex-col items-center justify-center bg-background text-muted-foreground p-8">
|
||||
<div className="flex flex-col items-center gap-4 max-w-md text-center">
|
||||
<div className="w-16 h-16 rounded-full bg-muted flex items-center justify-center">
|
||||
<svg className="w-8 h-8 text-muted-foreground" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-lg font-medium text-foreground mb-2">{title}</h3>
|
||||
<p className="text-sm text-muted-foreground">{message}</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="mt-4 px-4 py-2 text-sm bg-primary text-primary-foreground rounded-md hover:bg-primary/90 transition-colors"
|
||||
>
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
if (isSidebar) {
|
||||
return (
|
||||
<div className="w-full h-full flex flex-col bg-background">
|
||||
<div className="flex items-center justify-between px-3 py-1.5 border-b border-border flex-shrink-0">
|
||||
<div className="flex items-center gap-2 min-w-0 flex-1">
|
||||
<h3 className="text-sm font-medium text-gray-900 dark:text-white truncate">{file.name}</h3>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="p-1.5 text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white rounded-md hover:bg-gray-100 dark:hover:bg-gray-800 flex items-center justify-center"
|
||||
title="Close"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
{binaryContent}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const containerClassName = isFullscreen
|
||||
? 'fixed inset-0 z-[9999] bg-background flex flex-col'
|
||||
: 'fixed inset-0 z-[9999] md:bg-black/50 md:flex md:items-center md:justify-center md:p-4';
|
||||
|
||||
const innerClassName = isFullscreen
|
||||
? 'bg-background flex flex-col w-full h-full'
|
||||
: 'bg-background shadow-2xl flex flex-col w-full h-full md:rounded-lg md:shadow-2xl md:w-full md:max-w-2xl md:h-auto md:max-h-[60vh]';
|
||||
|
||||
return (
|
||||
<div className={containerClassName}>
|
||||
<div className={innerClassName}>
|
||||
<div className="flex items-center justify-between px-3 py-1.5 border-b border-border flex-shrink-0">
|
||||
<div className="flex items-center gap-2 min-w-0 flex-1">
|
||||
<h3 className="text-sm font-medium text-gray-900 dark:text-white truncate">{file.name}</h3>
|
||||
</div>
|
||||
<div className="flex items-center gap-0.5 shrink-0">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onToggleFullscreen}
|
||||
className="p-1.5 text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white rounded-md hover:bg-gray-100 dark:hover:bg-gray-800 flex items-center justify-center"
|
||||
title={isFullscreen ? 'Exit fullscreen' : 'Fullscreen'}
|
||||
>
|
||||
{isFullscreen ? (
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 9V4.5M9 9H4.5M9 9L3.5 3.5M9 15v4.5M9 15H4.5M9 15l-5.5 5.5M15 9h4.5M15 9V4.5M15 9l5.5-5.5M15 15h4.5M15 15v4.5m0-4.5l5.5 5.5" />
|
||||
</svg>
|
||||
) : (
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 8V4m0 0h4M4 4l5 5m11-1V4m0 0h-4m4 0l-5 5M4 16v4m0 0h4m-4 0l5-5m11 5l-5-5m5 5v-4m0 4h-4" />
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="p-1.5 text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white rounded-md hover:bg-gray-100 dark:hover:bg-gray-800 flex items-center justify-center"
|
||||
title="Close"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{binaryContent}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -49,13 +49,14 @@ export default function CodeEditorHeader({
|
||||
const saveTitle = saveSuccess ? labels.saved : saving ? labels.saving : labels.save;
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-between px-3 py-1.5 border-b border-border flex-shrink-0 min-w-0">
|
||||
<div className="flex items-center gap-2 min-w-0 flex-1">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center justify-between px-3 py-1.5 border-b border-border flex-shrink-0 min-w-0 gap-2">
|
||||
{/* File info - can shrink */}
|
||||
<div className="flex items-center gap-2 min-w-0 flex-1 shrink">
|
||||
<div className="min-w-0 shrink">
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<h3 className="text-sm font-medium text-gray-900 dark:text-white truncate">{file.name}</h3>
|
||||
{file.diffInfo && (
|
||||
<span className="text-[10px] bg-blue-100 dark:bg-blue-900 text-blue-600 dark:text-blue-300 px-1.5 py-0.5 rounded whitespace-nowrap">
|
||||
<span className="text-[10px] bg-blue-100 dark:bg-blue-900 text-blue-600 dark:text-blue-300 px-1.5 py-0.5 rounded whitespace-nowrap shrink-0">
|
||||
{labels.showingChanges}
|
||||
</span>
|
||||
)}
|
||||
@@ -64,12 +65,13 @@ export default function CodeEditorHeader({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-0.5 md:gap-1 flex-shrink-0">
|
||||
{/* Buttons - don't shrink, always visible */}
|
||||
<div className="flex items-center gap-0.5 shrink-0">
|
||||
{isMarkdownFile && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onToggleMarkdownPreview}
|
||||
className={`p-1.5 rounded-md min-w-[36px] min-h-[36px] md:min-w-0 md:min-h-0 flex items-center justify-center transition-colors ${
|
||||
className={`p-1.5 rounded-md flex items-center justify-center transition-colors ${
|
||||
markdownPreview
|
||||
? 'text-blue-600 dark:text-blue-400 bg-blue-50 dark:bg-blue-900/30'
|
||||
: 'text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white hover:bg-gray-100 dark:hover:bg-gray-800'
|
||||
@@ -83,7 +85,7 @@ export default function CodeEditorHeader({
|
||||
<button
|
||||
type="button"
|
||||
onClick={onOpenSettings}
|
||||
className="p-1.5 text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white rounded-md hover:bg-gray-100 dark:hover:bg-gray-800 min-w-[36px] min-h-[36px] md:min-w-0 md:min-h-0 flex items-center justify-center"
|
||||
className="p-1.5 text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white rounded-md hover:bg-gray-100 dark:hover:bg-gray-800 flex items-center justify-center"
|
||||
title={labels.settings}
|
||||
>
|
||||
<SettingsIcon className="w-4 h-4" />
|
||||
@@ -92,7 +94,7 @@ export default function CodeEditorHeader({
|
||||
<button
|
||||
type="button"
|
||||
onClick={onDownload}
|
||||
className="p-1.5 text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white rounded-md hover:bg-gray-100 dark:hover:bg-gray-800 min-w-[36px] min-h-[36px] md:min-w-0 md:min-h-0 flex items-center justify-center"
|
||||
className="p-1.5 text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white rounded-md hover:bg-gray-100 dark:hover:bg-gray-800 flex items-center justify-center"
|
||||
title={labels.download}
|
||||
>
|
||||
<Download className="w-4 h-4" />
|
||||
@@ -102,7 +104,7 @@ export default function CodeEditorHeader({
|
||||
type="button"
|
||||
onClick={onSave}
|
||||
disabled={saving}
|
||||
className={`p-1.5 rounded-md disabled:opacity-50 flex items-center justify-center transition-colors min-w-[36px] min-h-[36px] md:min-w-0 md:min-h-0 ${
|
||||
className={`p-1.5 rounded-md disabled:opacity-50 flex items-center justify-center transition-colors ${
|
||||
saveSuccess
|
||||
? 'text-green-600 dark:text-green-400 bg-green-50 dark:bg-green-900/30'
|
||||
: 'text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white hover:bg-gray-100 dark:hover:bg-gray-800'
|
||||
@@ -122,7 +124,7 @@ export default function CodeEditorHeader({
|
||||
<button
|
||||
type="button"
|
||||
onClick={onToggleFullscreen}
|
||||
className="hidden md:flex p-1.5 text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white rounded-md hover:bg-gray-100 dark:hover:bg-gray-800 items-center justify-center"
|
||||
className="p-1.5 text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white rounded-md hover:bg-gray-100 dark:hover:bg-gray-800 flex items-center justify-center"
|
||||
title={isFullscreen ? labels.exitFullscreen : labels.fullscreen}
|
||||
>
|
||||
{isFullscreen ? <Minimize2 className="w-4 h-4" /> : <Maximize2 className="w-4 h-4" />}
|
||||
@@ -132,7 +134,7 @@ export default function CodeEditorHeader({
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="p-1.5 text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white rounded-md hover:bg-gray-100 dark:hover:bg-gray-800 min-w-[36px] min-h-[36px] md:min-w-0 md:min-h-0 flex items-center justify-center"
|
||||
className="p-1.5 text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white rounded-md hover:bg-gray-100 dark:hover:bg-gray-800 flex items-center justify-center"
|
||||
title={labels.close}
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
|
||||
@@ -4,6 +4,7 @@ type UseExpandedDirectoriesResult = {
|
||||
expandedDirs: Set<string>;
|
||||
toggleDirectory: (path: string) => void;
|
||||
expandDirectories: (paths: string[]) => void;
|
||||
collapseAll: () => void;
|
||||
};
|
||||
|
||||
export function useExpandedDirectories(): UseExpandedDirectoriesResult {
|
||||
@@ -35,10 +36,15 @@ export function useExpandedDirectories(): UseExpandedDirectoriesResult {
|
||||
});
|
||||
}, []);
|
||||
|
||||
const collapseAll = useCallback(() => {
|
||||
setExpandedDirs(new Set());
|
||||
}, []);
|
||||
|
||||
return {
|
||||
expandedDirs,
|
||||
toggleDirectory,
|
||||
expandDirectories,
|
||||
collapseAll,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { api } from '../../../utils/api';
|
||||
import type { Project } from '../../../types/app';
|
||||
import type { FileTreeNode } from '../types/types';
|
||||
@@ -6,11 +6,18 @@ import type { FileTreeNode } from '../types/types';
|
||||
type UseFileTreeDataResult = {
|
||||
files: FileTreeNode[];
|
||||
loading: boolean;
|
||||
refreshFiles: () => void;
|
||||
};
|
||||
|
||||
export function useFileTreeData(selectedProject: Project | null): UseFileTreeDataResult {
|
||||
const [files, setFiles] = useState<FileTreeNode[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [refreshKey, setRefreshKey] = useState(0);
|
||||
const abortControllerRef = useRef<AbortController | null>(null);
|
||||
|
||||
const refreshFiles = useCallback(() => {
|
||||
setRefreshKey((prev) => prev + 1);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const projectName = selectedProject?.name;
|
||||
@@ -21,7 +28,12 @@ export function useFileTreeData(selectedProject: Project | null): UseFileTreeDat
|
||||
return;
|
||||
}
|
||||
|
||||
const abortController = new AbortController();
|
||||
// Abort previous request
|
||||
if (abortControllerRef.current) {
|
||||
abortControllerRef.current.abort();
|
||||
}
|
||||
abortControllerRef.current = new AbortController();
|
||||
|
||||
// Track mount state so aborted or late responses do not enqueue stale state updates.
|
||||
let isActive = true;
|
||||
|
||||
@@ -30,7 +42,7 @@ export function useFileTreeData(selectedProject: Project | null): UseFileTreeDat
|
||||
setLoading(true);
|
||||
}
|
||||
try {
|
||||
const response = await api.getFiles(projectName, { signal: abortController.signal });
|
||||
const response = await api.getFiles(projectName, { signal: abortControllerRef.current!.signal });
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
@@ -65,12 +77,13 @@ export function useFileTreeData(selectedProject: Project | null): UseFileTreeDat
|
||||
|
||||
return () => {
|
||||
isActive = false;
|
||||
abortController.abort();
|
||||
abortControllerRef.current?.abort();
|
||||
};
|
||||
}, [selectedProject?.name]);
|
||||
}, [selectedProject?.name, refreshKey]);
|
||||
|
||||
return {
|
||||
files,
|
||||
loading,
|
||||
refreshFiles,
|
||||
};
|
||||
}
|
||||
|
||||
382
src/components/file-tree/hooks/useFileTreeOperations.ts
Normal file
382
src/components/file-tree/hooks/useFileTreeOperations.ts
Normal file
@@ -0,0 +1,382 @@
|
||||
import { useCallback, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import JSZip from 'jszip';
|
||||
import { api } from '../../../utils/api';
|
||||
import type { FileTreeNode } from '../types/types';
|
||||
import type { Project } from '../../../types/app';
|
||||
|
||||
// Invalid filename characters
|
||||
const INVALID_FILENAME_CHARS = /[<>:"/\\|?*\x00-\x1f]/;
|
||||
const RESERVED_NAMES = /^(CON|PRN|AUX|NUL|COM[1-9]|LPT[1-9])$/i;
|
||||
|
||||
export type ToastMessage = {
|
||||
message: string;
|
||||
type: 'success' | 'error';
|
||||
};
|
||||
|
||||
export type DeleteConfirmation = {
|
||||
isOpen: boolean;
|
||||
item: FileTreeNode | null;
|
||||
};
|
||||
|
||||
export type UseFileTreeOperationsOptions = {
|
||||
selectedProject: Project | null;
|
||||
onRefresh: () => void;
|
||||
showToast: (message: string, type: 'success' | 'error') => void;
|
||||
};
|
||||
|
||||
export type UseFileTreeOperationsResult = {
|
||||
// Rename operations
|
||||
renamingItem: FileTreeNode | null;
|
||||
renameValue: string;
|
||||
handleStartRename: (item: FileTreeNode) => void;
|
||||
handleCancelRename: () => void;
|
||||
handleConfirmRename: () => Promise<void>;
|
||||
setRenameValue: (value: string) => void;
|
||||
|
||||
// Delete operations
|
||||
deleteConfirmation: DeleteConfirmation;
|
||||
handleStartDelete: (item: FileTreeNode) => void;
|
||||
handleCancelDelete: () => void;
|
||||
handleConfirmDelete: () => Promise<void>;
|
||||
|
||||
// Create operations
|
||||
isCreating: boolean;
|
||||
newItemParent: string;
|
||||
newItemType: 'file' | 'directory';
|
||||
newItemName: string;
|
||||
handleStartCreate: (parentPath: string, type: 'file' | 'directory') => void;
|
||||
handleCancelCreate: () => void;
|
||||
handleConfirmCreate: () => Promise<void>;
|
||||
setNewItemName: (name: string) => void;
|
||||
|
||||
// Other operations
|
||||
handleCopyPath: (item: FileTreeNode) => void;
|
||||
handleDownload: (item: FileTreeNode) => Promise<void>;
|
||||
|
||||
// Loading state
|
||||
operationLoading: boolean;
|
||||
|
||||
// Validation
|
||||
validateFilename: (name: string) => string | null;
|
||||
};
|
||||
|
||||
export function useFileTreeOperations({
|
||||
selectedProject,
|
||||
onRefresh,
|
||||
showToast,
|
||||
}: UseFileTreeOperationsOptions): UseFileTreeOperationsResult {
|
||||
const { t } = useTranslation();
|
||||
|
||||
// State
|
||||
const [renamingItem, setRenamingItem] = useState<FileTreeNode | null>(null);
|
||||
const [renameValue, setRenameValue] = useState('');
|
||||
const [deleteConfirmation, setDeleteConfirmation] = useState<DeleteConfirmation>({
|
||||
isOpen: false,
|
||||
item: null,
|
||||
});
|
||||
const [isCreating, setIsCreating] = useState(false);
|
||||
const [newItemParent, setNewItemParent] = useState('');
|
||||
const [newItemType, setNewItemType] = useState<'file' | 'directory'>('file');
|
||||
const [newItemName, setNewItemName] = useState('');
|
||||
const [operationLoading, setOperationLoading] = useState(false);
|
||||
|
||||
// Validation
|
||||
const validateFilename = useCallback((name: string): string | null => {
|
||||
if (!name || !name.trim()) {
|
||||
return t('fileTree.validation.emptyName', 'Filename cannot be empty');
|
||||
}
|
||||
if (INVALID_FILENAME_CHARS.test(name)) {
|
||||
return t('fileTree.validation.invalidChars', 'Filename contains invalid characters');
|
||||
}
|
||||
if (RESERVED_NAMES.test(name)) {
|
||||
return t('fileTree.validation.reserved', 'Filename is a reserved name');
|
||||
}
|
||||
if (/^\.+$/.test(name)) {
|
||||
return t('fileTree.validation.dotsOnly', 'Filename cannot be only dots');
|
||||
}
|
||||
return null;
|
||||
}, [t]);
|
||||
|
||||
// Rename operations
|
||||
const handleStartRename = useCallback((item: FileTreeNode) => {
|
||||
setRenamingItem(item);
|
||||
setRenameValue(item.name);
|
||||
setIsCreating(false);
|
||||
}, []);
|
||||
|
||||
const handleCancelRename = useCallback(() => {
|
||||
setRenamingItem(null);
|
||||
setRenameValue('');
|
||||
}, []);
|
||||
|
||||
const handleConfirmRename = useCallback(async () => {
|
||||
if (!renamingItem || !selectedProject) return;
|
||||
|
||||
const error = validateFilename(renameValue);
|
||||
if (error) {
|
||||
showToast(error, 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
if (renameValue === renamingItem.name) {
|
||||
handleCancelRename();
|
||||
return;
|
||||
}
|
||||
|
||||
setOperationLoading(true);
|
||||
try {
|
||||
const response = await api.renameFile(selectedProject.name, {
|
||||
oldPath: renamingItem.path,
|
||||
newName: renameValue,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const data = await response.json();
|
||||
throw new Error(data.error || 'Failed to rename');
|
||||
}
|
||||
|
||||
showToast(t('fileTree.toast.renamed', 'Renamed successfully'), 'success');
|
||||
onRefresh();
|
||||
handleCancelRename();
|
||||
} catch (err) {
|
||||
showToast((err as Error).message, 'error');
|
||||
} finally {
|
||||
setOperationLoading(false);
|
||||
}
|
||||
}, [renamingItem, renameValue, selectedProject, validateFilename, showToast, t, onRefresh, handleCancelRename]);
|
||||
|
||||
// Delete operations
|
||||
const handleStartDelete = useCallback((item: FileTreeNode) => {
|
||||
setDeleteConfirmation({ isOpen: true, item });
|
||||
}, []);
|
||||
|
||||
const handleCancelDelete = useCallback(() => {
|
||||
setDeleteConfirmation({ isOpen: false, item: null });
|
||||
}, []);
|
||||
|
||||
const handleConfirmDelete = useCallback(async () => {
|
||||
const { item } = deleteConfirmation;
|
||||
if (!item || !selectedProject) return;
|
||||
|
||||
setOperationLoading(true);
|
||||
try {
|
||||
const response = await api.deleteFile(selectedProject.name, {
|
||||
path: item.path,
|
||||
type: item.type,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const data = await response.json();
|
||||
throw new Error(data.error || 'Failed to delete');
|
||||
}
|
||||
|
||||
showToast(
|
||||
item.type === 'directory'
|
||||
? t('fileTree.toast.folderDeleted', 'Folder deleted')
|
||||
: t('fileTree.toast.fileDeleted', 'File deleted'),
|
||||
'success'
|
||||
);
|
||||
onRefresh();
|
||||
handleCancelDelete();
|
||||
} catch (err) {
|
||||
showToast((err as Error).message, 'error');
|
||||
} finally {
|
||||
setOperationLoading(false);
|
||||
}
|
||||
}, [deleteConfirmation, selectedProject, showToast, t, onRefresh, handleCancelDelete]);
|
||||
|
||||
// Create operations
|
||||
const handleStartCreate = useCallback((parentPath: string, type: 'file' | 'directory') => {
|
||||
setNewItemParent(parentPath || '');
|
||||
setNewItemType(type);
|
||||
setNewItemName(type === 'file' ? 'untitled.txt' : 'new-folder');
|
||||
setIsCreating(true);
|
||||
setRenamingItem(null);
|
||||
}, []);
|
||||
|
||||
const handleCancelCreate = useCallback(() => {
|
||||
setIsCreating(false);
|
||||
setNewItemParent('');
|
||||
setNewItemName('');
|
||||
}, []);
|
||||
|
||||
const handleConfirmCreate = useCallback(async () => {
|
||||
if (!selectedProject) return;
|
||||
|
||||
const error = validateFilename(newItemName);
|
||||
if (error) {
|
||||
showToast(error, 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
setOperationLoading(true);
|
||||
try {
|
||||
const response = await api.createFile(selectedProject.name, {
|
||||
path: newItemParent,
|
||||
type: newItemType,
|
||||
name: newItemName,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const data = await response.json();
|
||||
throw new Error(data.error || 'Failed to create');
|
||||
}
|
||||
|
||||
showToast(
|
||||
newItemType === 'file'
|
||||
? t('fileTree.toast.fileCreated', 'File created successfully')
|
||||
: t('fileTree.toast.folderCreated', 'Folder created successfully'),
|
||||
'success'
|
||||
);
|
||||
onRefresh();
|
||||
handleCancelCreate();
|
||||
} catch (err) {
|
||||
showToast((err as Error).message, 'error');
|
||||
} finally {
|
||||
setOperationLoading(false);
|
||||
}
|
||||
}, [selectedProject, newItemParent, newItemType, newItemName, validateFilename, showToast, t, onRefresh, handleCancelCreate]);
|
||||
|
||||
// Copy path to clipboard
|
||||
const handleCopyPath = useCallback((item: FileTreeNode) => {
|
||||
navigator.clipboard.writeText(item.path).catch(() => {
|
||||
// Clipboard API may fail in some contexts (e.g., non-HTTPS)
|
||||
showToast(t('fileTree.toast.copyFailed', 'Failed to copy path'), 'error');
|
||||
return;
|
||||
});
|
||||
showToast(t('fileTree.toast.pathCopied', 'Path copied to clipboard'), 'success');
|
||||
}, [showToast, t]);
|
||||
|
||||
// Download file or folder
|
||||
const handleDownload = useCallback(async (item: FileTreeNode) => {
|
||||
if (!selectedProject) return;
|
||||
|
||||
setOperationLoading(true);
|
||||
try {
|
||||
if (item.type === 'directory') {
|
||||
// Download folder as ZIP
|
||||
await downloadFolderAsZip(item);
|
||||
} else {
|
||||
// Download single file
|
||||
await downloadSingleFile(item);
|
||||
}
|
||||
} catch (err) {
|
||||
showToast((err as Error).message, 'error');
|
||||
} finally {
|
||||
setOperationLoading(false);
|
||||
}
|
||||
}, [selectedProject, showToast]);
|
||||
|
||||
// Download a single file
|
||||
const downloadSingleFile = useCallback(async (item: FileTreeNode) => {
|
||||
if (!selectedProject) return;
|
||||
|
||||
const response = await api.readFile(selectedProject.name, item.path);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to download file');
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
const content = data.content;
|
||||
|
||||
const blob = new Blob([content], { type: 'text/plain' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const anchor = document.createElement('a');
|
||||
|
||||
anchor.href = url;
|
||||
anchor.download = item.name;
|
||||
|
||||
document.body.appendChild(anchor);
|
||||
anchor.click();
|
||||
document.body.removeChild(anchor);
|
||||
|
||||
URL.revokeObjectURL(url);
|
||||
}, [selectedProject]);
|
||||
|
||||
// Download folder as ZIP
|
||||
const downloadFolderAsZip = useCallback(async (folder: FileTreeNode) => {
|
||||
if (!selectedProject) return;
|
||||
|
||||
const zip = new JSZip();
|
||||
|
||||
// Recursively get all files in the folder
|
||||
const collectFiles = async (node: FileTreeNode, currentPath: string) => {
|
||||
const fullPath = currentPath ? `${currentPath}/${node.name}` : node.name;
|
||||
|
||||
if (node.type === 'file') {
|
||||
// Fetch file content
|
||||
const response = await api.readFile(selectedProject.name, node.path);
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
zip.file(fullPath, data.content);
|
||||
}
|
||||
} else if (node.type === 'directory' && node.children) {
|
||||
// Recursively process children
|
||||
for (const child of node.children) {
|
||||
await collectFiles(child, fullPath);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// If the folder has children, process them
|
||||
if (folder.children && folder.children.length > 0) {
|
||||
for (const child of folder.children) {
|
||||
await collectFiles(child, '');
|
||||
}
|
||||
}
|
||||
|
||||
// Generate ZIP file
|
||||
const zipBlob = await zip.generateAsync({ type: 'blob' });
|
||||
const url = URL.createObjectURL(zipBlob);
|
||||
const anchor = document.createElement('a');
|
||||
|
||||
anchor.href = url;
|
||||
anchor.download = `${folder.name}.zip`;
|
||||
|
||||
document.body.appendChild(anchor);
|
||||
anchor.click();
|
||||
document.body.removeChild(anchor);
|
||||
|
||||
URL.revokeObjectURL(url);
|
||||
|
||||
showToast(t('fileTree.toast.folderDownloaded', 'Folder downloaded as ZIP'), 'success');
|
||||
}, [selectedProject, showToast, t]);
|
||||
|
||||
return {
|
||||
// Rename operations
|
||||
renamingItem,
|
||||
renameValue,
|
||||
handleStartRename,
|
||||
handleCancelRename,
|
||||
handleConfirmRename,
|
||||
setRenameValue,
|
||||
|
||||
// Delete operations
|
||||
deleteConfirmation,
|
||||
handleStartDelete,
|
||||
handleCancelDelete,
|
||||
handleConfirmDelete,
|
||||
|
||||
// Create operations
|
||||
isCreating,
|
||||
newItemParent,
|
||||
newItemType,
|
||||
newItemName,
|
||||
handleStartCreate,
|
||||
handleCancelCreate,
|
||||
handleConfirmCreate,
|
||||
setNewItemName,
|
||||
|
||||
// Other operations
|
||||
handleCopyPath,
|
||||
handleDownload,
|
||||
|
||||
// Loading state
|
||||
operationLoading,
|
||||
|
||||
// Validation
|
||||
validateFilename,
|
||||
};
|
||||
}
|
||||
205
src/components/file-tree/hooks/useFileTreeUpload.ts
Normal file
205
src/components/file-tree/hooks/useFileTreeUpload.ts
Normal file
@@ -0,0 +1,205 @@
|
||||
import { useCallback, useState, useRef } from 'react';
|
||||
import type { Project } from '../../../types/app';
|
||||
import { api } from '../../../utils/api';
|
||||
|
||||
type UseFileTreeUploadOptions = {
|
||||
selectedProject: Project | null;
|
||||
onRefresh: () => void;
|
||||
showToast: (message: string, type: 'success' | 'error') => void;
|
||||
};
|
||||
|
||||
// Helper function to read all files from a directory entry recursively
|
||||
const readAllDirectoryEntries = async (directoryEntry: FileSystemDirectoryEntry, basePath = ''): Promise<File[]> => {
|
||||
const files: File[] = [];
|
||||
|
||||
const reader = directoryEntry.createReader();
|
||||
let entries: FileSystemEntry[] = [];
|
||||
|
||||
// Read all entries from the directory (may need multiple reads)
|
||||
let batch: FileSystemEntry[];
|
||||
do {
|
||||
batch = await new Promise<FileSystemEntry[]>((resolve, reject) => {
|
||||
reader.readEntries(resolve, reject);
|
||||
});
|
||||
entries = entries.concat(batch);
|
||||
} while (batch.length > 0);
|
||||
|
||||
// Files to ignore (system files)
|
||||
const ignoredFiles = ['.DS_Store', 'Thumbs.db', 'desktop.ini'];
|
||||
|
||||
for (const entry of entries) {
|
||||
const entryPath = basePath ? `${basePath}/${entry.name}` : entry.name;
|
||||
|
||||
if (entry.isFile) {
|
||||
const fileEntry = entry as FileSystemFileEntry;
|
||||
const file = await new Promise<File>((resolve, reject) => {
|
||||
fileEntry.file(resolve, reject);
|
||||
});
|
||||
|
||||
// Skip ignored files
|
||||
if (ignoredFiles.includes(file.name)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Create a new file with the relative path as the name
|
||||
const fileWithPath = new File([file], entryPath, {
|
||||
type: file.type,
|
||||
lastModified: file.lastModified,
|
||||
});
|
||||
files.push(fileWithPath);
|
||||
} else if (entry.isDirectory) {
|
||||
const dirEntry = entry as FileSystemDirectoryEntry;
|
||||
const subFiles = await readAllDirectoryEntries(dirEntry, entryPath);
|
||||
files.push(...subFiles);
|
||||
}
|
||||
}
|
||||
|
||||
return files;
|
||||
};
|
||||
|
||||
export const useFileTreeUpload = ({
|
||||
selectedProject,
|
||||
onRefresh,
|
||||
showToast,
|
||||
}: UseFileTreeUploadOptions) => {
|
||||
const [isDragOver, setIsDragOver] = useState(false);
|
||||
const [dropTarget, setDropTarget] = useState<string | null>(null);
|
||||
const [operationLoading, setOperationLoading] = useState(false);
|
||||
const treeRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const handleDragEnter = useCallback((e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setIsDragOver(true);
|
||||
}, []);
|
||||
|
||||
const handleDragOver = useCallback((e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
}, []);
|
||||
|
||||
const handleDragLeave = useCallback((e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
// Only set isDragOver to false if we're leaving the entire tree
|
||||
if (treeRef.current && !treeRef.current.contains(e.relatedTarget as Node)) {
|
||||
setIsDragOver(false);
|
||||
setDropTarget(null);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleDrop = useCallback(async (e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setIsDragOver(false);
|
||||
|
||||
const targetPath = dropTarget || '';
|
||||
setOperationLoading(true);
|
||||
|
||||
try {
|
||||
const files: File[] = [];
|
||||
|
||||
// Use DataTransferItemList for folder support
|
||||
const items = e.dataTransfer.items;
|
||||
if (items) {
|
||||
for (const item of Array.from(items)) {
|
||||
if (item.kind === 'file') {
|
||||
const entry = item.webkitGetAsEntry ? item.webkitGetAsEntry() : null;
|
||||
|
||||
if (entry) {
|
||||
if (entry.isFile) {
|
||||
const file = await new Promise<File>((resolve, reject) => {
|
||||
(entry as FileSystemFileEntry).file(resolve, reject);
|
||||
});
|
||||
files.push(file);
|
||||
} else if (entry.isDirectory) {
|
||||
// Pass the directory name as basePath so files include the folder path
|
||||
const dirFiles = await readAllDirectoryEntries(entry as FileSystemDirectoryEntry, entry.name);
|
||||
files.push(...dirFiles);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Fallback for browsers that don't support webkitGetAsEntry
|
||||
const fileList = e.dataTransfer.files;
|
||||
for (const file of Array.from(fileList)) {
|
||||
files.push(file);
|
||||
}
|
||||
}
|
||||
|
||||
if (files.length === 0) {
|
||||
setOperationLoading(false);
|
||||
setDropTarget(null);
|
||||
return;
|
||||
}
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append('targetPath', targetPath);
|
||||
|
||||
// Store relative paths separately since FormData strips path info from File.name
|
||||
const relativePaths: string[] = [];
|
||||
files.forEach((file) => {
|
||||
// Create a new file with just the filename (without path) for FormData
|
||||
// but store the relative path separately
|
||||
const cleanFile = new File([file], file.name.split('/').pop()!, {
|
||||
type: file.type,
|
||||
lastModified: file.lastModified
|
||||
});
|
||||
formData.append('files', cleanFile);
|
||||
relativePaths.push(file.name); // Keep the full relative path
|
||||
});
|
||||
|
||||
// Send relative paths as a JSON array
|
||||
formData.append('relativePaths', JSON.stringify(relativePaths));
|
||||
|
||||
const response = await api.post(
|
||||
`/projects/${encodeURIComponent(selectedProject!.name)}/files/upload`,
|
||||
formData
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
const data = await response.json();
|
||||
throw new Error(data.error || 'Upload failed');
|
||||
}
|
||||
|
||||
showToast(
|
||||
`Uploaded ${files.length} file(s)`,
|
||||
'success'
|
||||
);
|
||||
onRefresh();
|
||||
} catch (err) {
|
||||
console.error('Upload error:', err);
|
||||
showToast(err instanceof Error ? err.message : 'Upload failed', 'error');
|
||||
} finally {
|
||||
setOperationLoading(false);
|
||||
setDropTarget(null);
|
||||
}
|
||||
}, [dropTarget, selectedProject, onRefresh, showToast]);
|
||||
|
||||
const handleItemDragOver = useCallback((e: React.DragEvent, itemPath: string) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setDropTarget(itemPath);
|
||||
}, []);
|
||||
|
||||
const handleItemDrop = useCallback((e: React.DragEvent, itemPath: string) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setDropTarget(itemPath);
|
||||
}, []);
|
||||
|
||||
return {
|
||||
isDragOver,
|
||||
dropTarget,
|
||||
operationLoading,
|
||||
treeRef,
|
||||
handleDragEnter,
|
||||
handleDragOver,
|
||||
handleDragLeave,
|
||||
handleDrop,
|
||||
handleItemDragOver,
|
||||
handleItemDrop,
|
||||
setDropTarget,
|
||||
};
|
||||
};
|
||||
@@ -1,12 +1,15 @@
|
||||
import { useCallback, useState } from 'react';
|
||||
import { useCallback, useState, useEffect, useRef } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { AlertTriangle, Check, X, Loader2, Folder, Upload } from 'lucide-react';
|
||||
import { cn } from '../../../lib/utils';
|
||||
import ImageViewer from './ImageViewer';
|
||||
import { ICON_SIZE_CLASS, getFileIconData } from '../constants/fileIcons';
|
||||
import { useExpandedDirectories } from '../hooks/useExpandedDirectories';
|
||||
import { useFileTreeData } from '../hooks/useFileTreeData';
|
||||
import { useFileTreeOperations } from '../hooks/useFileTreeOperations';
|
||||
import { useFileTreeSearch } from '../hooks/useFileTreeSearch';
|
||||
import { useFileTreeViewMode } from '../hooks/useFileTreeViewMode';
|
||||
import { useFileTreeUpload } from '../hooks/useFileTreeUpload';
|
||||
import type { FileTreeImageSelection, FileTreeNode } from '../types/types';
|
||||
import { formatFileSize, formatRelativeTime, isImageFile } from '../utils/fileTreeUtils';
|
||||
import FileTreeBody from './FileTreeBody';
|
||||
@@ -14,24 +17,72 @@ import FileTreeDetailedColumns from './FileTreeDetailedColumns';
|
||||
import FileTreeHeader from './FileTreeHeader';
|
||||
import FileTreeLoadingState from './FileTreeLoadingState';
|
||||
import { Project } from '../../../types/app';
|
||||
import { Input } from '../../ui/input';
|
||||
import { ScrollArea } from '../../ui/scroll-area';
|
||||
|
||||
type FileTreeProps = {
|
||||
type FileTreeProps = {
|
||||
selectedProject: Project | null;
|
||||
onFileOpen?: (filePath: string) => void;
|
||||
}
|
||||
};
|
||||
|
||||
export default function FileTree({ selectedProject, onFileOpen }: FileTreeProps) {
|
||||
const { t } = useTranslation();
|
||||
const [selectedImage, setSelectedImage] = useState<FileTreeImageSelection | null>(null);
|
||||
const [toast, setToast] = useState<{ message: string; type: 'success' | 'error' } | null>(null);
|
||||
const newItemInputRef = useRef<HTMLInputElement>(null);
|
||||
const renameInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const { files, loading } = useFileTreeData(selectedProject);
|
||||
// Show toast notification
|
||||
const showToast = useCallback((message: string, type: 'success' | 'error') => {
|
||||
setToast({ message, type });
|
||||
}, []);
|
||||
|
||||
// Auto-hide toast
|
||||
useEffect(() => {
|
||||
if (toast) {
|
||||
const timer = setTimeout(() => setToast(null), 3000);
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
}, [toast]);
|
||||
|
||||
const { files, loading, refreshFiles } = useFileTreeData(selectedProject);
|
||||
const { viewMode, changeViewMode } = useFileTreeViewMode();
|
||||
const { expandedDirs, toggleDirectory, expandDirectories } = useExpandedDirectories();
|
||||
const { expandedDirs, toggleDirectory, expandDirectories, collapseAll } = useExpandedDirectories();
|
||||
const { searchQuery, setSearchQuery, filteredFiles } = useFileTreeSearch({
|
||||
files,
|
||||
expandDirectories,
|
||||
});
|
||||
|
||||
// File operations
|
||||
const operations = useFileTreeOperations({
|
||||
selectedProject,
|
||||
onRefresh: refreshFiles,
|
||||
showToast,
|
||||
});
|
||||
|
||||
// File upload (drag and drop)
|
||||
const upload = useFileTreeUpload({
|
||||
selectedProject,
|
||||
onRefresh: refreshFiles,
|
||||
showToast,
|
||||
});
|
||||
|
||||
// Focus input when creating new item
|
||||
useEffect(() => {
|
||||
if (operations.isCreating && newItemInputRef.current) {
|
||||
newItemInputRef.current.focus();
|
||||
newItemInputRef.current.select();
|
||||
}
|
||||
}, [operations.isCreating]);
|
||||
|
||||
// Focus input when renaming
|
||||
useEffect(() => {
|
||||
if (operations.renamingItem && renameInputRef.current) {
|
||||
renameInputRef.current.focus();
|
||||
renameInputRef.current.select();
|
||||
}
|
||||
}, [operations.renamingItem]);
|
||||
|
||||
const renderFileIcon = useCallback((filename: string) => {
|
||||
const { icon: Icon, color } = getFileIconData(filename);
|
||||
return <Icon className={cn(ICON_SIZE_CLASS, color)} />;
|
||||
@@ -70,27 +121,99 @@ export default function FileTree({ selectedProject, onFileOpen }: FileTreeProps)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="h-full flex flex-col bg-background">
|
||||
<div
|
||||
ref={upload.treeRef}
|
||||
className="h-full flex flex-col bg-background relative"
|
||||
onDragEnter={upload.handleDragEnter}
|
||||
onDragOver={upload.handleDragOver}
|
||||
onDragLeave={upload.handleDragLeave}
|
||||
onDrop={upload.handleDrop}
|
||||
>
|
||||
{/* Drag overlay */}
|
||||
{upload.isDragOver && (
|
||||
<div className="absolute inset-0 z-50 bg-blue-500/10 border-2 border-dashed border-blue-500 flex items-center justify-center">
|
||||
<div className="bg-background/95 px-6 py-4 rounded-lg shadow-lg flex items-center gap-3">
|
||||
<Upload className="w-6 h-6 text-blue-500" />
|
||||
<span className="text-sm font-medium">{t('fileTree.dropToUpload', 'Drop files to upload')}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<FileTreeHeader
|
||||
viewMode={viewMode}
|
||||
onViewModeChange={changeViewMode}
|
||||
searchQuery={searchQuery}
|
||||
onSearchQueryChange={setSearchQuery}
|
||||
onNewFile={() => operations.handleStartCreate('', 'file')}
|
||||
onNewFolder={() => operations.handleStartCreate('', 'directory')}
|
||||
onRefresh={refreshFiles}
|
||||
onCollapseAll={collapseAll}
|
||||
loading={loading}
|
||||
operationLoading={operations.operationLoading}
|
||||
/>
|
||||
|
||||
{viewMode === 'detailed' && filteredFiles.length > 0 && <FileTreeDetailedColumns />}
|
||||
|
||||
<FileTreeBody
|
||||
files={files}
|
||||
filteredFiles={filteredFiles}
|
||||
searchQuery={searchQuery}
|
||||
viewMode={viewMode}
|
||||
expandedDirs={expandedDirs}
|
||||
onItemClick={handleItemClick}
|
||||
renderFileIcon={renderFileIcon}
|
||||
formatFileSize={formatFileSize}
|
||||
formatRelativeTime={formatRelativeTimeLabel}
|
||||
/>
|
||||
<ScrollArea className="flex-1 px-2 py-1">
|
||||
{/* New item input */}
|
||||
{operations.isCreating && (
|
||||
<div
|
||||
className="flex items-center gap-1.5 py-[3px] pr-2 mb-1"
|
||||
style={{ paddingLeft: `${(operations.newItemParent.split('/').length - 1) * 16 + 4}px` }}
|
||||
>
|
||||
{operations.newItemType === 'directory' ? (
|
||||
<Folder className={cn(ICON_SIZE_CLASS, 'text-blue-500')} />
|
||||
) : (
|
||||
<span className="ml-[18px]">{renderFileIcon(operations.newItemName)}</span>
|
||||
)}
|
||||
<Input
|
||||
ref={newItemInputRef}
|
||||
type="text"
|
||||
value={operations.newItemName}
|
||||
onChange={(e) => operations.setNewItemName(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
e.stopPropagation();
|
||||
if (e.key === 'Enter') operations.handleConfirmCreate();
|
||||
if (e.key === 'Escape') operations.handleCancelCreate();
|
||||
}}
|
||||
onBlur={() => {
|
||||
setTimeout(() => {
|
||||
if (operations.isCreating) operations.handleConfirmCreate();
|
||||
}, 100);
|
||||
}}
|
||||
className="h-6 text-sm flex-1"
|
||||
disabled={operations.operationLoading}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<FileTreeBody
|
||||
files={files}
|
||||
filteredFiles={filteredFiles}
|
||||
searchQuery={searchQuery}
|
||||
viewMode={viewMode}
|
||||
expandedDirs={expandedDirs}
|
||||
onItemClick={handleItemClick}
|
||||
renderFileIcon={renderFileIcon}
|
||||
formatFileSize={formatFileSize}
|
||||
formatRelativeTime={formatRelativeTimeLabel}
|
||||
onRename={operations.handleStartRename}
|
||||
onDelete={operations.handleStartDelete}
|
||||
onNewFile={(path) => operations.handleStartCreate(path, 'file')}
|
||||
onNewFolder={(path) => operations.handleStartCreate(path, 'directory')}
|
||||
onCopyPath={operations.handleCopyPath}
|
||||
onDownload={operations.handleDownload}
|
||||
onRefresh={refreshFiles}
|
||||
// Pass rename state and handlers for inline editing
|
||||
renamingItem={operations.renamingItem}
|
||||
renameValue={operations.renameValue}
|
||||
setRenameValue={operations.setRenameValue}
|
||||
handleConfirmRename={operations.handleConfirmRename}
|
||||
handleCancelRename={operations.handleCancelRename}
|
||||
renameInputRef={renameInputRef}
|
||||
operationLoading={operations.operationLoading}
|
||||
/>
|
||||
</ScrollArea>
|
||||
|
||||
{selectedImage && (
|
||||
<ImageViewer
|
||||
@@ -98,6 +221,70 @@ export default function FileTree({ selectedProject, onFileOpen }: FileTreeProps)
|
||||
onClose={() => setSelectedImage(null)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Delete Confirmation Dialog */}
|
||||
{operations.deleteConfirmation.isOpen && operations.deleteConfirmation.item && (
|
||||
<div className="fixed inset-0 z-[9999] flex items-center justify-center bg-black/50">
|
||||
<div className="bg-background border border-border rounded-lg shadow-lg p-4 max-w-sm mx-4">
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<div className="p-2 rounded-full bg-red-100 dark:bg-red-900/30">
|
||||
<AlertTriangle className="w-5 h-5 text-red-600 dark:text-red-400" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-medium text-foreground">
|
||||
{t('fileTree.delete.title', 'Delete {{type}}', {
|
||||
type: operations.deleteConfirmation.item.type === 'directory' ? 'Folder' : 'File'
|
||||
})}
|
||||
</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{operations.deleteConfirmation.item.name}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground mb-4">
|
||||
{operations.deleteConfirmation.item.type === 'directory'
|
||||
? t('fileTree.delete.folderWarning', 'This folder and all its contents will be permanently deleted.')
|
||||
: t('fileTree.delete.fileWarning', 'This file will be permanently deleted.')}
|
||||
</p>
|
||||
<div className="flex justify-end gap-2">
|
||||
<button
|
||||
onClick={operations.handleCancelDelete}
|
||||
disabled={operations.operationLoading}
|
||||
className="px-3 py-1.5 text-sm rounded-md hover:bg-accent transition-colors"
|
||||
>
|
||||
{t('common.cancel', 'Cancel')}
|
||||
</button>
|
||||
<button
|
||||
onClick={operations.handleConfirmDelete}
|
||||
disabled={operations.operationLoading}
|
||||
className="px-3 py-1.5 text-sm rounded-md bg-red-600 text-white hover:bg-red-700 transition-colors disabled:opacity-50 flex items-center gap-2"
|
||||
>
|
||||
{operations.operationLoading && <Loader2 className="w-4 h-4 animate-spin" />}
|
||||
{t('fileTree.delete.confirm', 'Delete')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Toast Notification */}
|
||||
{toast && (
|
||||
<div
|
||||
className={cn(
|
||||
'fixed bottom-4 right-4 z-[9999] px-4 py-2 rounded-lg shadow-lg flex items-center gap-2 animate-in slide-in-from-bottom-2',
|
||||
toast.type === 'success'
|
||||
? 'bg-green-600 text-white'
|
||||
: 'bg-red-600 text-white'
|
||||
)}
|
||||
>
|
||||
{toast.type === 'success' ? (
|
||||
<Check className="w-4 h-4" />
|
||||
) : (
|
||||
<X className="w-4 h-4" />
|
||||
)}
|
||||
<span className="text-sm">{toast.message}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import type { ReactNode } from 'react';
|
||||
import type { ReactNode, RefObject } from 'react';
|
||||
import { Folder, Search } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { ScrollArea } from '../../ui/scroll-area';
|
||||
import type { FileTreeNode, FileTreeViewMode } from '../types/types';
|
||||
import FileTreeEmptyState from './FileTreeEmptyState';
|
||||
import FileTreeList from './FileTreeList';
|
||||
@@ -16,6 +15,21 @@ type FileTreeBodyProps = {
|
||||
renderFileIcon: (filename: string) => ReactNode;
|
||||
formatFileSize: (bytes?: number) => string;
|
||||
formatRelativeTime: (date?: string) => string;
|
||||
onRename?: (item: FileTreeNode) => void;
|
||||
onDelete?: (item: FileTreeNode) => void;
|
||||
onNewFile?: (path: string) => void;
|
||||
onNewFolder?: (path: string) => void;
|
||||
onCopyPath?: (item: FileTreeNode) => void;
|
||||
onDownload?: (item: FileTreeNode) => void;
|
||||
onRefresh?: () => void;
|
||||
// Rename state for inline editing
|
||||
renamingItem?: FileTreeNode | null;
|
||||
renameValue?: string;
|
||||
setRenameValue?: (value: string) => void;
|
||||
handleConfirmRename?: () => void;
|
||||
handleCancelRename?: () => void;
|
||||
renameInputRef?: RefObject<HTMLInputElement>;
|
||||
operationLoading?: boolean;
|
||||
};
|
||||
|
||||
export default function FileTreeBody({
|
||||
@@ -28,11 +42,25 @@ export default function FileTreeBody({
|
||||
renderFileIcon,
|
||||
formatFileSize,
|
||||
formatRelativeTime,
|
||||
onRename,
|
||||
onDelete,
|
||||
onNewFile,
|
||||
onNewFolder,
|
||||
onCopyPath,
|
||||
onDownload,
|
||||
onRefresh,
|
||||
renamingItem,
|
||||
renameValue,
|
||||
setRenameValue,
|
||||
handleConfirmRename,
|
||||
handleCancelRename,
|
||||
renameInputRef,
|
||||
operationLoading,
|
||||
}: FileTreeBodyProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<ScrollArea className="flex-1 px-2 py-1">
|
||||
<>
|
||||
{files.length === 0 ? (
|
||||
<FileTreeEmptyState
|
||||
icon={Folder}
|
||||
@@ -54,9 +82,22 @@ export default function FileTreeBody({
|
||||
renderFileIcon={renderFileIcon}
|
||||
formatFileSize={formatFileSize}
|
||||
formatRelativeTime={formatRelativeTime}
|
||||
onRename={onRename}
|
||||
onDelete={onDelete}
|
||||
onNewFile={onNewFile}
|
||||
onNewFolder={onNewFolder}
|
||||
onCopyPath={onCopyPath}
|
||||
onDownload={onDownload}
|
||||
onRefresh={onRefresh}
|
||||
renamingItem={renamingItem}
|
||||
renameValue={renameValue}
|
||||
setRenameValue={setRenameValue}
|
||||
handleConfirmRename={handleConfirmRename}
|
||||
handleCancelRename={handleCancelRename}
|
||||
renameInputRef={renameInputRef}
|
||||
operationLoading={operationLoading}
|
||||
/>
|
||||
)}
|
||||
</ScrollArea>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { Eye, List, Search, TableProperties, X } from 'lucide-react';
|
||||
import { ChevronDown, Eye, FileText, FolderPlus, List, RefreshCw, Search, TableProperties, X } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Button } from '../../ui/button';
|
||||
import { Input } from '../../ui/input';
|
||||
import { cn } from '../../../lib/utils';
|
||||
import type { FileTreeViewMode } from '../types/types';
|
||||
|
||||
type FileTreeHeaderProps = {
|
||||
@@ -9,6 +10,14 @@ type FileTreeHeaderProps = {
|
||||
onViewModeChange: (mode: FileTreeViewMode) => void;
|
||||
searchQuery: string;
|
||||
onSearchQueryChange: (query: string) => void;
|
||||
// Toolbar actions
|
||||
onNewFile?: () => void;
|
||||
onNewFolder?: () => void;
|
||||
onRefresh?: () => void;
|
||||
onCollapseAll?: () => void;
|
||||
// Loading state
|
||||
loading?: boolean;
|
||||
operationLoading?: boolean;
|
||||
};
|
||||
|
||||
export default function FileTreeHeader({
|
||||
@@ -16,20 +25,83 @@ export default function FileTreeHeader({
|
||||
onViewModeChange,
|
||||
searchQuery,
|
||||
onSearchQueryChange,
|
||||
onNewFile,
|
||||
onNewFolder,
|
||||
onRefresh,
|
||||
onCollapseAll,
|
||||
loading,
|
||||
operationLoading,
|
||||
}: FileTreeHeaderProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<div className="px-3 pt-3 pb-2 border-b border-border space-y-2">
|
||||
{/* Title and Toolbar */}
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-sm font-medium text-foreground">{t('fileTree.files')}</h3>
|
||||
<div className="flex gap-0.5">
|
||||
<div className="flex items-center gap-0.5">
|
||||
{/* Action buttons */}
|
||||
{onNewFile && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 w-7 p-0"
|
||||
onClick={onNewFile}
|
||||
title={t('fileTree.newFile', 'New File (Cmd+N)')}
|
||||
aria-label={t('fileTree.newFile', 'New File (Cmd+N)')}
|
||||
disabled={operationLoading}
|
||||
>
|
||||
<FileText className="w-3.5 h-3.5" />
|
||||
</Button>
|
||||
)}
|
||||
{onNewFolder && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 w-7 p-0"
|
||||
onClick={onNewFolder}
|
||||
title={t('fileTree.newFolder', 'New Folder (Cmd+Shift+N)')}
|
||||
aria-label={t('fileTree.newFolder', 'New Folder (Cmd+Shift+N)')}
|
||||
disabled={operationLoading}
|
||||
>
|
||||
<FolderPlus className="w-3.5 h-3.5" />
|
||||
</Button>
|
||||
)}
|
||||
{onRefresh && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 w-7 p-0"
|
||||
onClick={onRefresh}
|
||||
title={t('fileTree.refresh', 'Refresh')}
|
||||
aria-label={t('fileTree.refresh', 'Refresh')}
|
||||
disabled={operationLoading}
|
||||
>
|
||||
<RefreshCw className={cn('w-3.5 h-3.5', loading && 'animate-spin')} />
|
||||
</Button>
|
||||
)}
|
||||
{onCollapseAll && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 w-7 p-0"
|
||||
onClick={onCollapseAll}
|
||||
title={t('fileTree.collapseAll', 'Collapse All')}
|
||||
aria-label={t('fileTree.collapseAll', 'Collapse All')}
|
||||
>
|
||||
<ChevronDown className="w-3.5 h-3.5" />
|
||||
</Button>
|
||||
)}
|
||||
{/* Divider */}
|
||||
<div className="w-px h-4 bg-border mx-0.5" />
|
||||
{/* View mode buttons */}
|
||||
<Button
|
||||
variant={viewMode === 'simple' ? 'default' : 'ghost'}
|
||||
size="sm"
|
||||
className="h-7 w-7 p-0"
|
||||
onClick={() => onViewModeChange('simple')}
|
||||
title={t('fileTree.simpleView')}
|
||||
aria-label={t('fileTree.simpleView')}
|
||||
>
|
||||
<List className="w-3.5 h-3.5" />
|
||||
</Button>
|
||||
@@ -39,6 +111,7 @@ export default function FileTreeHeader({
|
||||
className="h-7 w-7 p-0"
|
||||
onClick={() => onViewModeChange('compact')}
|
||||
title={t('fileTree.compactView')}
|
||||
aria-label={t('fileTree.compactView')}
|
||||
>
|
||||
<Eye className="w-3.5 h-3.5" />
|
||||
</Button>
|
||||
@@ -48,12 +121,14 @@ export default function FileTreeHeader({
|
||||
className="h-7 w-7 p-0"
|
||||
onClick={() => onViewModeChange('detailed')}
|
||||
title={t('fileTree.detailedView')}
|
||||
aria-label={t('fileTree.detailedView')}
|
||||
>
|
||||
<TableProperties className="w-3.5 h-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Search Bar */}
|
||||
<div className="relative">
|
||||
<Search className="absolute left-2 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-muted-foreground" />
|
||||
<Input
|
||||
@@ -70,6 +145,7 @@ export default function FileTreeHeader({
|
||||
className="absolute right-0.5 top-1/2 -translate-y-1/2 h-5 w-5 p-0 hover:bg-accent"
|
||||
onClick={() => onSearchQueryChange('')}
|
||||
title={t('fileTree.clearSearch')}
|
||||
aria-label={t('fileTree.clearSearch')}
|
||||
>
|
||||
<X className="w-3 h-3" />
|
||||
</Button>
|
||||
@@ -78,4 +154,3 @@ export default function FileTreeHeader({
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { ReactNode } from 'react';
|
||||
import type { ReactNode, RefObject } from 'react';
|
||||
import type { FileTreeNode as FileTreeNodeType, FileTreeViewMode } from '../types/types';
|
||||
import FileTreeNode from './FileTreeNode';
|
||||
|
||||
@@ -10,6 +10,21 @@ type FileTreeListProps = {
|
||||
renderFileIcon: (filename: string) => ReactNode;
|
||||
formatFileSize: (bytes?: number) => string;
|
||||
formatRelativeTime: (date?: string) => string;
|
||||
onRename?: (item: FileTreeNodeType) => void;
|
||||
onDelete?: (item: FileTreeNodeType) => void;
|
||||
onNewFile?: (path: string) => void;
|
||||
onNewFolder?: (path: string) => void;
|
||||
onCopyPath?: (item: FileTreeNodeType) => void;
|
||||
onDownload?: (item: FileTreeNodeType) => void;
|
||||
onRefresh?: () => void;
|
||||
// Rename state for inline editing
|
||||
renamingItem?: FileTreeNodeType | null;
|
||||
renameValue?: string;
|
||||
setRenameValue?: (value: string) => void;
|
||||
handleConfirmRename?: () => void;
|
||||
handleCancelRename?: () => void;
|
||||
renameInputRef?: RefObject<HTMLInputElement>;
|
||||
operationLoading?: boolean;
|
||||
};
|
||||
|
||||
export default function FileTreeList({
|
||||
@@ -20,6 +35,20 @@ export default function FileTreeList({
|
||||
renderFileIcon,
|
||||
formatFileSize,
|
||||
formatRelativeTime,
|
||||
onRename,
|
||||
onDelete,
|
||||
onNewFile,
|
||||
onNewFolder,
|
||||
onCopyPath,
|
||||
onDownload,
|
||||
onRefresh,
|
||||
renamingItem,
|
||||
renameValue,
|
||||
setRenameValue,
|
||||
handleConfirmRename,
|
||||
handleCancelRename,
|
||||
renameInputRef,
|
||||
operationLoading,
|
||||
}: FileTreeListProps) {
|
||||
return (
|
||||
<div>
|
||||
@@ -34,9 +63,22 @@ export default function FileTreeList({
|
||||
renderFileIcon={renderFileIcon}
|
||||
formatFileSize={formatFileSize}
|
||||
formatRelativeTime={formatRelativeTime}
|
||||
onRename={onRename}
|
||||
onDelete={onDelete}
|
||||
onNewFile={onNewFile}
|
||||
onNewFolder={onNewFolder}
|
||||
onCopyPath={onCopyPath}
|
||||
onDownload={onDownload}
|
||||
onRefresh={onRefresh}
|
||||
renamingItem={renamingItem}
|
||||
renameValue={renameValue}
|
||||
setRenameValue={setRenameValue}
|
||||
handleConfirmRename={handleConfirmRename}
|
||||
handleCancelRename={handleCancelRename}
|
||||
renameInputRef={renameInputRef}
|
||||
operationLoading={operationLoading}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import type { ReactNode } from 'react';
|
||||
import type { ReactNode, RefObject } from 'react';
|
||||
import { ChevronRight, Folder, FolderOpen } from 'lucide-react';
|
||||
import { cn } from '../../../lib/utils';
|
||||
import FileContextMenu from '../../FileContextMenu';
|
||||
import { Input } from '../../ui/input';
|
||||
import type { FileTreeNode as FileTreeNodeType, FileTreeViewMode } from '../types/types';
|
||||
|
||||
type FileTreeNodeProps = {
|
||||
@@ -12,6 +14,21 @@ type FileTreeNodeProps = {
|
||||
renderFileIcon: (filename: string) => ReactNode;
|
||||
formatFileSize: (bytes?: number) => string;
|
||||
formatRelativeTime: (date?: string) => string;
|
||||
onRename?: (item: FileTreeNodeType) => void;
|
||||
onDelete?: (item: FileTreeNodeType) => void;
|
||||
onNewFile?: (path: string) => void;
|
||||
onNewFolder?: (path: string) => void;
|
||||
onCopyPath?: (item: FileTreeNodeType) => void;
|
||||
onDownload?: (item: FileTreeNodeType) => void;
|
||||
onRefresh?: () => void;
|
||||
// Rename state for inline editing
|
||||
renamingItem?: FileTreeNodeType | null;
|
||||
renameValue?: string;
|
||||
setRenameValue?: (value: string) => void;
|
||||
handleConfirmRename?: () => void;
|
||||
handleCancelRename?: () => void;
|
||||
renameInputRef?: RefObject<HTMLInputElement>;
|
||||
operationLoading?: boolean;
|
||||
};
|
||||
|
||||
type TreeItemIconProps = {
|
||||
@@ -51,10 +68,25 @@ export default function FileTreeNode({
|
||||
renderFileIcon,
|
||||
formatFileSize,
|
||||
formatRelativeTime,
|
||||
onRename,
|
||||
onDelete,
|
||||
onNewFile,
|
||||
onNewFolder,
|
||||
onCopyPath,
|
||||
onDownload,
|
||||
onRefresh,
|
||||
renamingItem,
|
||||
renameValue,
|
||||
setRenameValue,
|
||||
handleConfirmRename,
|
||||
handleCancelRename,
|
||||
renameInputRef,
|
||||
operationLoading,
|
||||
}: FileTreeNodeProps) {
|
||||
const isDirectory = item.type === 'directory';
|
||||
const isOpen = isDirectory && expandedDirs.has(item.path);
|
||||
const hasChildren = Boolean(isDirectory && item.children && item.children.length > 0);
|
||||
const isRenaming = renamingItem?.path === item.path;
|
||||
|
||||
const nameClassName = cn(
|
||||
'text-[13px] leading-tight truncate',
|
||||
@@ -72,47 +104,100 @@ export default function FileTreeNode({
|
||||
(isDirectory && !isOpen) || !isDirectory ? 'border-l-2 border-transparent' : '',
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="select-none">
|
||||
// Render rename input if this item is being renamed
|
||||
if (isRenaming && setRenameValue && handleConfirmRename && handleCancelRename) {
|
||||
return (
|
||||
<div
|
||||
className={rowClassName}
|
||||
className={cn(rowClassName, 'bg-accent/30')}
|
||||
style={{ paddingLeft: `${level * 16 + 4}px` }}
|
||||
onClick={() => onItemClick(item)}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{viewMode === 'detailed' ? (
|
||||
<>
|
||||
<div className="col-span-5 flex items-center gap-1.5 min-w-0">
|
||||
<TreeItemIcon item={item} isOpen={isOpen} renderFileIcon={renderFileIcon} />
|
||||
<span className={nameClassName}>{item.name}</span>
|
||||
</div>
|
||||
<div className="col-span-2 text-sm text-muted-foreground tabular-nums">
|
||||
{item.type === 'file' ? formatFileSize(item.size) : ''}
|
||||
</div>
|
||||
<div className="col-span-3 text-sm text-muted-foreground">{formatRelativeTime(item.modified)}</div>
|
||||
<div className="col-span-2 text-sm text-muted-foreground font-mono">{item.permissionsRwx || ''}</div>
|
||||
</>
|
||||
) : viewMode === 'compact' ? (
|
||||
<>
|
||||
<div className="flex items-center gap-1.5 min-w-0">
|
||||
<TreeItemIcon item={item} isOpen={isOpen} renderFileIcon={renderFileIcon} />
|
||||
<span className={nameClassName}>{item.name}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 text-sm text-muted-foreground flex-shrink-0 ml-2">
|
||||
{item.type === 'file' && (
|
||||
<>
|
||||
<span className="tabular-nums">{formatFileSize(item.size)}</span>
|
||||
<span className="font-mono">{item.permissionsRwx}</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<TreeItemIcon item={item} isOpen={isOpen} renderFileIcon={renderFileIcon} />
|
||||
<Input
|
||||
ref={renameInputRef}
|
||||
type="text"
|
||||
value={renameValue || ''}
|
||||
onChange={(e) => setRenameValue(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
e.stopPropagation();
|
||||
if (e.key === 'Enter') handleConfirmRename();
|
||||
if (e.key === 'Escape') handleCancelRename();
|
||||
}}
|
||||
onBlur={() => {
|
||||
setTimeout(() => {
|
||||
handleConfirmRename();
|
||||
}, 100);
|
||||
}}
|
||||
className="h-6 text-sm flex-1"
|
||||
disabled={operationLoading}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const rowContent = (
|
||||
<div
|
||||
className={rowClassName}
|
||||
style={{ paddingLeft: `${level * 16 + 4}px` }}
|
||||
onClick={() => onItemClick(item)}
|
||||
>
|
||||
{viewMode === 'detailed' ? (
|
||||
<>
|
||||
<div className="col-span-5 flex items-center gap-1.5 min-w-0">
|
||||
<TreeItemIcon item={item} isOpen={isOpen} renderFileIcon={renderFileIcon} />
|
||||
<span className={nameClassName}>{item.name}</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="col-span-2 text-sm text-muted-foreground tabular-nums">
|
||||
{item.type === 'file' ? formatFileSize(item.size) : ''}
|
||||
</div>
|
||||
<div className="col-span-3 text-sm text-muted-foreground">{formatRelativeTime(item.modified)}</div>
|
||||
<div className="col-span-2 text-sm text-muted-foreground font-mono">{item.permissionsRwx || ''}</div>
|
||||
</>
|
||||
) : viewMode === 'compact' ? (
|
||||
<>
|
||||
<div className="flex items-center gap-1.5 min-w-0">
|
||||
<TreeItemIcon item={item} isOpen={isOpen} renderFileIcon={renderFileIcon} />
|
||||
<span className={nameClassName}>{item.name}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 text-sm text-muted-foreground flex-shrink-0 ml-2">
|
||||
{item.type === 'file' && (
|
||||
<>
|
||||
<span className="tabular-nums">{formatFileSize(item.size)}</span>
|
||||
<span className="font-mono">{item.permissionsRwx}</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<TreeItemIcon item={item} isOpen={isOpen} renderFileIcon={renderFileIcon} />
|
||||
<span className={nameClassName}>{item.name}</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
// Check if context menu callbacks are provided
|
||||
const hasContextMenu = onRename || onDelete || onNewFile || onNewFolder || onCopyPath || onDownload || onRefresh;
|
||||
|
||||
return (
|
||||
<div className="select-none">
|
||||
{hasContextMenu ? (
|
||||
<FileContextMenu
|
||||
item={item}
|
||||
onRename={onRename}
|
||||
onDelete={onDelete}
|
||||
onNewFile={onNewFile}
|
||||
onNewFolder={onNewFolder}
|
||||
onCopyPath={onCopyPath}
|
||||
onDownload={onDownload}
|
||||
onRefresh={onRefresh}
|
||||
>
|
||||
{rowContent}
|
||||
</FileContextMenu>
|
||||
) : (
|
||||
rowContent
|
||||
)}
|
||||
|
||||
{isDirectory && isOpen && hasChildren && (
|
||||
<div className="relative">
|
||||
@@ -132,6 +217,20 @@ export default function FileTreeNode({
|
||||
renderFileIcon={renderFileIcon}
|
||||
formatFileSize={formatFileSize}
|
||||
formatRelativeTime={formatRelativeTime}
|
||||
onRename={onRename}
|
||||
onDelete={onDelete}
|
||||
onNewFile={onNewFile}
|
||||
onNewFolder={onNewFolder}
|
||||
onCopyPath={onCopyPath}
|
||||
onDownload={onDownload}
|
||||
onRefresh={onRefresh}
|
||||
renamingItem={renamingItem}
|
||||
renameValue={renameValue}
|
||||
setRenameValue={setRenameValue}
|
||||
handleConfirmRename={handleConfirmRename}
|
||||
handleCancelRename={handleCancelRename}
|
||||
renameInputRef={renameInputRef}
|
||||
operationLoading={operationLoading}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -107,7 +107,7 @@ function MainContent({
|
||||
/>
|
||||
|
||||
<div className="flex-1 flex min-h-0 overflow-hidden">
|
||||
<div className={`flex flex-col min-h-0 min-w-0 overflow-hidden ${editorExpanded ? 'hidden' : ''} flex-1`}>
|
||||
<div className={`flex flex-col min-h-0 min-w-[200px] overflow-hidden ${editorExpanded ? 'hidden' : ''} flex-1`}>
|
||||
<div className={`h-full ${activeTab === 'chat' ? 'block' : 'hidden'}`}>
|
||||
<ErrorBoundary showDetails>
|
||||
<ChatInterface
|
||||
|
||||
@@ -18,6 +18,7 @@ export const SETTINGS_MAIN_TABS: SettingsMainTab[] = [
|
||||
'git',
|
||||
'api',
|
||||
'tasks',
|
||||
'notifications',
|
||||
];
|
||||
|
||||
export const AGENT_PROVIDERS: AgentProvider[] = ['claude', 'cursor', 'codex'];
|
||||
|
||||
@@ -20,6 +20,7 @@ import type {
|
||||
McpServer,
|
||||
McpToolsResult,
|
||||
McpTestResult,
|
||||
NotificationPreferencesState,
|
||||
ProjectSortOrder,
|
||||
SettingsMainTab,
|
||||
SettingsProject,
|
||||
@@ -95,9 +96,14 @@ type CodexSettingsStorage = {
|
||||
permissionMode?: CodexPermissionMode;
|
||||
};
|
||||
|
||||
type NotificationPreferencesResponse = {
|
||||
success?: boolean;
|
||||
preferences?: NotificationPreferencesState;
|
||||
};
|
||||
|
||||
type ActiveLoginProvider = AgentProvider | '';
|
||||
|
||||
const KNOWN_MAIN_TABS: SettingsMainTab[] = ['agents', 'appearance', 'git', 'api', 'tasks'];
|
||||
const KNOWN_MAIN_TABS: SettingsMainTab[] = ['agents', 'appearance', 'git', 'api', 'tasks', 'notifications'];
|
||||
|
||||
const normalizeMainTab = (tab: string): SettingsMainTab => {
|
||||
// Keep backwards compatibility with older callers that still pass "tools".
|
||||
@@ -185,6 +191,18 @@ const createEmptyCursorPermissions = (): CursorPermissionsState => ({
|
||||
...DEFAULT_CURSOR_PERMISSIONS,
|
||||
});
|
||||
|
||||
const createDefaultNotificationPreferences = (): NotificationPreferencesState => ({
|
||||
channels: {
|
||||
inApp: true,
|
||||
webPush: false,
|
||||
},
|
||||
events: {
|
||||
actionRequired: true,
|
||||
stop: true,
|
||||
error: true,
|
||||
},
|
||||
});
|
||||
|
||||
export function useSettingsController({ isOpen, initialTab, projects, onClose }: UseSettingsControllerArgs) {
|
||||
const { isDarkMode, toggleDarkMode } = useTheme() as ThemeContextValue;
|
||||
const closeTimerRef = useRef<number | null>(null);
|
||||
@@ -204,6 +222,9 @@ export function useSettingsController({ isOpen, initialTab, projects, onClose }:
|
||||
const [cursorPermissions, setCursorPermissions] = useState<CursorPermissionsState>(() => (
|
||||
createEmptyCursorPermissions()
|
||||
));
|
||||
const [notificationPreferences, setNotificationPreferences] = useState<NotificationPreferencesState>(() => (
|
||||
createDefaultNotificationPreferences()
|
||||
));
|
||||
const [codexPermissionMode, setCodexPermissionMode] = useState<CodexPermissionMode>('default');
|
||||
const [geminiPermissionMode, setGeminiPermissionMode] = useState<GeminiPermissionMode>('default');
|
||||
|
||||
@@ -669,6 +690,22 @@ export function useSettingsController({ isOpen, initialTab, projects, onClose }:
|
||||
);
|
||||
setGeminiPermissionMode(savedGeminiSettings.permissionMode || 'default');
|
||||
|
||||
try {
|
||||
const notificationResponse = await authenticatedFetch('/api/settings/notification-preferences');
|
||||
if (notificationResponse.ok) {
|
||||
const notificationData = await toResponseJson<NotificationPreferencesResponse>(notificationResponse);
|
||||
if (notificationData.success && notificationData.preferences) {
|
||||
setNotificationPreferences(notificationData.preferences);
|
||||
} else {
|
||||
setNotificationPreferences(createDefaultNotificationPreferences());
|
||||
}
|
||||
} else {
|
||||
setNotificationPreferences(createDefaultNotificationPreferences());
|
||||
}
|
||||
} catch {
|
||||
setNotificationPreferences(createDefaultNotificationPreferences());
|
||||
}
|
||||
|
||||
await Promise.all([
|
||||
fetchMcpServers(),
|
||||
fetchCursorMcpServers(),
|
||||
@@ -678,6 +715,7 @@ export function useSettingsController({ isOpen, initialTab, projects, onClose }:
|
||||
console.error('Error loading settings:', error);
|
||||
setClaudePermissions(createEmptyClaudePermissions());
|
||||
setCursorPermissions(createEmptyCursorPermissions());
|
||||
setNotificationPreferences(createDefaultNotificationPreferences());
|
||||
setCodexPermissionMode('default');
|
||||
setProjectSortOrder('name');
|
||||
}
|
||||
@@ -698,7 +736,7 @@ export function useSettingsController({ isOpen, initialTab, projects, onClose }:
|
||||
void checkAuthStatus(loginProvider);
|
||||
}, [checkAuthStatus, loginProvider]);
|
||||
|
||||
const saveSettings = useCallback(() => {
|
||||
const saveSettings = useCallback(async () => {
|
||||
setIsSaving(true);
|
||||
setSaveStatus(null);
|
||||
|
||||
@@ -729,6 +767,14 @@ export function useSettingsController({ isOpen, initialTab, projects, onClose }:
|
||||
lastUpdated: now,
|
||||
}));
|
||||
|
||||
const notificationResponse = await authenticatedFetch('/api/settings/notification-preferences', {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(notificationPreferences),
|
||||
});
|
||||
if (!notificationResponse.ok) {
|
||||
throw new Error('Failed to save notification preferences');
|
||||
}
|
||||
|
||||
setSaveStatus('success');
|
||||
if (closeTimerRef.current !== null) {
|
||||
window.clearTimeout(closeTimerRef.current);
|
||||
@@ -749,6 +795,7 @@ export function useSettingsController({ isOpen, initialTab, projects, onClose }:
|
||||
cursorPermissions.allowedCommands,
|
||||
cursorPermissions.disallowedCommands,
|
||||
cursorPermissions.skipPermissions,
|
||||
notificationPreferences,
|
||||
onClose,
|
||||
projectSortOrder,
|
||||
]);
|
||||
@@ -825,6 +872,8 @@ export function useSettingsController({ isOpen, initialTab, projects, onClose }:
|
||||
setClaudePermissions,
|
||||
cursorPermissions,
|
||||
setCursorPermissions,
|
||||
notificationPreferences,
|
||||
setNotificationPreferences,
|
||||
codexPermissionMode,
|
||||
setCodexPermissionMode,
|
||||
mcpServers,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { Dispatch, SetStateAction } from 'react';
|
||||
|
||||
export type SettingsMainTab = 'agents' | 'appearance' | 'git' | 'api' | 'tasks';
|
||||
export type SettingsMainTab = 'agents' | 'appearance' | 'git' | 'api' | 'tasks' | 'notifications';
|
||||
export type AgentProvider = 'claude' | 'cursor' | 'codex' | 'gemini';
|
||||
export type AgentCategory = 'account' | 'permissions' | 'mcp';
|
||||
export type ProjectSortOrder = 'name' | 'date';
|
||||
@@ -105,6 +105,18 @@ export type ClaudePermissionsState = {
|
||||
skipPermissions: boolean;
|
||||
};
|
||||
|
||||
export type NotificationPreferencesState = {
|
||||
channels: {
|
||||
inApp: boolean;
|
||||
webPush: boolean;
|
||||
};
|
||||
events: {
|
||||
actionRequired: boolean;
|
||||
stop: boolean;
|
||||
error: boolean;
|
||||
};
|
||||
};
|
||||
|
||||
export type CursorPermissionsState = {
|
||||
allowedCommands: string[];
|
||||
disallowedCommands: string[];
|
||||
|
||||
@@ -9,8 +9,10 @@ import AgentsSettingsTab from '../view/tabs/agents-settings/AgentsSettingsTab';
|
||||
import AppearanceSettingsTab from '../view/tabs/AppearanceSettingsTab';
|
||||
import CredentialsSettingsTab from '../view/tabs/api-settings/CredentialsSettingsTab';
|
||||
import GitSettingsTab from '../view/tabs/git-settings/GitSettingsTab';
|
||||
import NotificationsSettingsTab from '../view/tabs/NotificationsSettingsTab';
|
||||
import TasksSettingsTab from '../view/tabs/tasks-settings/TasksSettingsTab';
|
||||
import { useSettingsController } from '../hooks/useSettingsController';
|
||||
import { useWebPush } from '../../../hooks/useWebPush';
|
||||
import type { AgentProvider, SettingsProject, SettingsProps } from '../types/types';
|
||||
|
||||
type LoginModalProps = {
|
||||
@@ -38,6 +40,8 @@ function Settings({ isOpen, onClose, projects = [], initialTab = 'agents' }: Set
|
||||
updateCodeEditorSetting,
|
||||
claudePermissions,
|
||||
setClaudePermissions,
|
||||
notificationPreferences,
|
||||
setNotificationPreferences,
|
||||
cursorPermissions,
|
||||
setCursorPermissions,
|
||||
codexPermissionMode,
|
||||
@@ -82,6 +86,32 @@ function Settings({ isOpen, onClose, projects = [], initialTab = 'agents' }: Set
|
||||
onClose,
|
||||
});
|
||||
|
||||
const {
|
||||
permission: pushPermission,
|
||||
isSubscribed: isPushSubscribed,
|
||||
isLoading: isPushLoading,
|
||||
subscribe: pushSubscribe,
|
||||
unsubscribe: pushUnsubscribe,
|
||||
} = useWebPush();
|
||||
|
||||
const handleEnablePush = async () => {
|
||||
await pushSubscribe();
|
||||
// Server sets webPush: true in preferences on subscribe; sync local state
|
||||
setNotificationPreferences({
|
||||
...notificationPreferences,
|
||||
channels: { ...notificationPreferences.channels, webPush: true },
|
||||
});
|
||||
};
|
||||
|
||||
const handleDisablePush = async () => {
|
||||
await pushUnsubscribe();
|
||||
// Server sets webPush: false in preferences on unsubscribe; sync local state
|
||||
setNotificationPreferences({
|
||||
...notificationPreferences,
|
||||
channels: { ...notificationPreferences.channels, webPush: false },
|
||||
});
|
||||
};
|
||||
|
||||
if (!isOpen) {
|
||||
return null;
|
||||
}
|
||||
@@ -171,6 +201,18 @@ function Settings({ isOpen, onClose, projects = [], initialTab = 'agents' }: Set
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'notifications' && (
|
||||
<NotificationsSettingsTab
|
||||
notificationPreferences={notificationPreferences}
|
||||
onNotificationPreferencesChange={setNotificationPreferences}
|
||||
pushPermission={pushPermission}
|
||||
isPushSubscribed={isPushSubscribed}
|
||||
isPushLoading={isPushLoading}
|
||||
onEnablePush={handleEnablePush}
|
||||
onDisablePush={handleDisablePush}
|
||||
/>
|
||||
)}
|
||||
|
||||
{activeTab === 'api' && (
|
||||
<div className="space-y-6 md:space-y-8">
|
||||
<CredentialsSettingsTab />
|
||||
|
||||
@@ -19,6 +19,7 @@ const TAB_CONFIG: MainTabConfig[] = [
|
||||
{ id: 'git', labelKey: 'mainTabs.git', icon: GitBranch },
|
||||
{ id: 'api', labelKey: 'mainTabs.apiTokens', icon: Key },
|
||||
{ id: 'tasks', labelKey: 'mainTabs.tasks' },
|
||||
{ id: 'notifications', labelKey: 'mainTabs.notifications' },
|
||||
];
|
||||
|
||||
export default function SettingsMainTabs({ activeTab, onChange }: SettingsMainTabsProps) {
|
||||
@@ -26,7 +27,7 @@ export default function SettingsMainTabs({ activeTab, onChange }: SettingsMainTa
|
||||
|
||||
return (
|
||||
<div className="border-b border-border">
|
||||
<div className="flex px-4 md:px-6" role="tablist" aria-label={t('mainTabs.label', { defaultValue: 'Settings' })}>
|
||||
<div className="flex px-4 md:px-6 overflow-x-auto scrollbar-hide" role="tablist" aria-label={t('mainTabs.label', { defaultValue: 'Settings' })}>
|
||||
{TAB_CONFIG.map((tab) => {
|
||||
const Icon = tab.icon;
|
||||
const isActive = activeTab === tab.id;
|
||||
@@ -37,7 +38,7 @@ export default function SettingsMainTabs({ activeTab, onChange }: SettingsMainTa
|
||||
role="tab"
|
||||
aria-selected={isActive}
|
||||
onClick={() => onChange(tab.id)}
|
||||
className={`px-4 py-3 text-sm font-medium border-b-2 transition-colors ${
|
||||
className={`px-4 py-3 text-sm font-medium border-b-2 transition-colors whitespace-nowrap ${
|
||||
isActive
|
||||
? 'border-blue-600 text-blue-600 dark:text-blue-400'
|
||||
: 'border-transparent text-muted-foreground hover:text-foreground'
|
||||
|
||||
145
src/components/settings/view/tabs/NotificationsSettingsTab.tsx
Normal file
145
src/components/settings/view/tabs/NotificationsSettingsTab.tsx
Normal file
@@ -0,0 +1,145 @@
|
||||
import { Bell, BellOff, BellRing, Loader2 } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import type { NotificationPreferencesState } from '../../types/types';
|
||||
|
||||
type NotificationsSettingsTabProps = {
|
||||
notificationPreferences: NotificationPreferencesState;
|
||||
onNotificationPreferencesChange: (value: NotificationPreferencesState) => void;
|
||||
pushPermission: NotificationPermission | 'unsupported';
|
||||
isPushSubscribed: boolean;
|
||||
isPushLoading: boolean;
|
||||
onEnablePush: () => void;
|
||||
onDisablePush: () => void;
|
||||
};
|
||||
|
||||
export default function NotificationsSettingsTab({
|
||||
notificationPreferences,
|
||||
onNotificationPreferencesChange,
|
||||
pushPermission,
|
||||
isPushSubscribed,
|
||||
isPushLoading,
|
||||
onEnablePush,
|
||||
onDisablePush,
|
||||
}: NotificationsSettingsTabProps) {
|
||||
const { t } = useTranslation('settings');
|
||||
|
||||
const pushSupported = pushPermission !== 'unsupported';
|
||||
const pushDenied = pushPermission === 'denied';
|
||||
|
||||
return (
|
||||
<div className="space-y-6 md:space-y-8">
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<Bell className="w-5 h-5 text-blue-600" />
|
||||
<h3 className="text-lg font-medium text-foreground">{t('notifications.title')}</h3>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">{t('notifications.description')}</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4 bg-card border border-border rounded-lg p-4">
|
||||
<h4 className="font-medium text-foreground">{t('notifications.webPush.title')}</h4>
|
||||
{!pushSupported ? (
|
||||
<p className="text-sm text-muted-foreground">{t('notifications.webPush.unsupported')}</p>
|
||||
) : pushDenied ? (
|
||||
<p className="text-sm text-muted-foreground">{t('notifications.webPush.denied')}</p>
|
||||
) : (
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
type="button"
|
||||
disabled={isPushLoading}
|
||||
onClick={() => {
|
||||
if (isPushSubscribed) {
|
||||
onDisablePush();
|
||||
} else {
|
||||
onEnablePush();
|
||||
}
|
||||
}}
|
||||
className={`inline-flex items-center gap-2 px-4 py-2 text-sm font-medium rounded-md transition-colors disabled:opacity-50 disabled:cursor-not-allowed ${
|
||||
isPushSubscribed
|
||||
? 'bg-red-100 text-red-700 hover:bg-red-200 dark:bg-red-900/30 dark:text-red-400 dark:hover:bg-red-900/50'
|
||||
: 'bg-blue-600 text-white hover:bg-blue-700 dark:bg-blue-500 dark:hover:bg-blue-600'
|
||||
}`}
|
||||
>
|
||||
{isPushLoading ? (
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
) : isPushSubscribed ? (
|
||||
<BellOff className="w-4 h-4" />
|
||||
) : (
|
||||
<BellRing className="w-4 h-4" />
|
||||
)}
|
||||
{isPushLoading
|
||||
? t('notifications.webPush.loading')
|
||||
: isPushSubscribed
|
||||
? t('notifications.webPush.disable')
|
||||
: t('notifications.webPush.enable')}
|
||||
</button>
|
||||
{isPushSubscribed && (
|
||||
<span className="text-sm text-green-600 dark:text-green-400">
|
||||
{t('notifications.webPush.enabled')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-4 bg-card border border-border rounded-lg p-4">
|
||||
<h4 className="font-medium text-foreground">{t('notifications.events.title')}</h4>
|
||||
<div className="space-y-3">
|
||||
<label className="flex items-center gap-2 text-sm text-foreground">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={notificationPreferences.events.actionRequired}
|
||||
onChange={(event) =>
|
||||
onNotificationPreferencesChange({
|
||||
...notificationPreferences,
|
||||
events: {
|
||||
...notificationPreferences.events,
|
||||
actionRequired: event.target.checked,
|
||||
},
|
||||
})
|
||||
}
|
||||
className="w-4 h-4"
|
||||
/>
|
||||
{t('notifications.events.actionRequired')}
|
||||
</label>
|
||||
|
||||
<label className="flex items-center gap-2 text-sm text-foreground">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={notificationPreferences.events.stop}
|
||||
onChange={(event) =>
|
||||
onNotificationPreferencesChange({
|
||||
...notificationPreferences,
|
||||
events: {
|
||||
...notificationPreferences.events,
|
||||
stop: event.target.checked,
|
||||
},
|
||||
})
|
||||
}
|
||||
className="w-4 h-4"
|
||||
/>
|
||||
{t('notifications.events.stop')}
|
||||
</label>
|
||||
|
||||
<label className="flex items-center gap-2 text-sm text-foreground">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={notificationPreferences.events.error}
|
||||
onChange={(event) =>
|
||||
onNotificationPreferencesChange({
|
||||
...notificationPreferences,
|
||||
events: {
|
||||
...notificationPreferences.events,
|
||||
error: event.target.checked,
|
||||
},
|
||||
})
|
||||
}
|
||||
className="w-4 h-4"
|
||||
/>
|
||||
{t('notifications.events.error')}
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -256,6 +256,7 @@ function ClaudePermissions({
|
||||
<li><code className="bg-blue-100 dark:bg-blue-800 px-1 rounded">"Bash(rm:*)"</code> {t('permissions.toolExamples.bashRm')}</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,16 @@
|
||||
import { Settings, Sparkles, PanelLeftOpen } from 'lucide-react';
|
||||
import type { TFunction } from 'i18next';
|
||||
|
||||
const DISCORD_INVITE_URL = 'https://discord.gg/buxwujPNRE';
|
||||
|
||||
function DiscordIcon({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg className={className} fill="currentColor" viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path d="M20.317 4.37a19.791 19.791 0 0 0-4.885-1.515.074.074 0 0 0-.079.037c-.21.375-.444.864-.608 1.25a18.27 18.27 0 0 0-5.487 0 12.64 12.64 0 0 0-.617-1.25.077.077 0 0 0-.079-.037A19.736 19.736 0 0 0 3.677 4.37a.07.07 0 0 0-.032.027C.533 9.046-.32 13.58.099 18.057a.082.082 0 0 0 .031.057 19.9 19.9 0 0 0 5.993 3.03.078.078 0 0 0 .084-.028c.462-.63.874-1.295 1.226-1.994a.076.076 0 0 0-.041-.106 13.107 13.107 0 0 1-1.872-.892.077.077 0 0 1-.008-.128 10.2 10.2 0 0 0 .372-.292.074.074 0 0 1 .077-.01c3.928 1.793 8.18 1.793 12.062 0a.074.074 0 0 1 .078.01c.12.098.246.198.373.292a.077.077 0 0 1-.006.127 12.299 12.299 0 0 1-1.873.892.077.077 0 0 0-.041.107c.36.698.772 1.362 1.225 1.993a.076.076 0 0 0 .084.028 19.839 19.839 0 0 0 6.002-3.03.077.077 0 0 0 .032-.054c.5-5.177-.838-9.674-3.549-13.66a.061.061 0 0 0-.031-.03zM8.02 15.33c-1.183 0-2.157-1.085-2.157-2.419 0-1.333.956-2.419 2.157-2.419 1.21 0 2.176 1.095 2.157 2.42 0 1.333-.956 2.418-2.157 2.418zm7.975 0c-1.183 0-2.157-1.085-2.157-2.419 0-1.333.955-2.419 2.157-2.419 1.21 0 2.176 1.095 2.157 2.42 0 1.333-.946 2.418-2.157 2.418z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
type SidebarCollapsedProps = {
|
||||
onExpand: () => void;
|
||||
onShowSettings: () => void;
|
||||
@@ -40,6 +50,18 @@ export default function SidebarCollapsed({
|
||||
<Settings className="w-4 h-4 text-muted-foreground group-hover:text-foreground transition-colors" />
|
||||
</button>
|
||||
|
||||
{/* Discord */}
|
||||
<a
|
||||
href={DISCORD_INVITE_URL}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="w-8 h-8 rounded-lg flex items-center justify-center hover:bg-accent/80 transition-colors group"
|
||||
aria-label={t('actions.joinCommunity')}
|
||||
title={t('actions.joinCommunity')}
|
||||
>
|
||||
<DiscordIcon className="w-4 h-4 text-muted-foreground group-hover:text-foreground transition-colors" />
|
||||
</a>
|
||||
|
||||
{/* Update indicator */}
|
||||
{updateAvailable && (
|
||||
<button
|
||||
|
||||
@@ -2,6 +2,16 @@ import { Settings, ArrowUpCircle } from 'lucide-react';
|
||||
import type { TFunction } from 'i18next';
|
||||
import type { ReleaseInfo } from '../../../../types/sharedTypes';
|
||||
|
||||
const DISCORD_INVITE_URL = 'https://discord.gg/buxwujPNRE';
|
||||
|
||||
function DiscordIcon({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg className={className} fill="currentColor" viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path d="M20.317 4.37a19.791 19.791 0 0 0-4.885-1.515.074.074 0 0 0-.079.037c-.21.375-.444.864-.608 1.25a18.27 18.27 0 0 0-5.487 0 12.64 12.64 0 0 0-.617-1.25.077.077 0 0 0-.079-.037A19.736 19.736 0 0 0 3.677 4.37a.07.07 0 0 0-.032.027C.533 9.046-.32 13.58.099 18.057a.082.082 0 0 0 .031.057 19.9 19.9 0 0 0 5.993 3.03.078.078 0 0 0 .084-.028c.462-.63.874-1.295 1.226-1.994a.076.076 0 0 0-.041-.106 13.107 13.107 0 0 1-1.872-.892.077.077 0 0 1-.008-.128 10.2 10.2 0 0 0 .372-.292.074.074 0 0 1 .077-.01c3.928 1.793 8.18 1.793 12.062 0a.074.074 0 0 1 .078.01c.12.098.246.198.373.292a.077.077 0 0 1-.006.127 12.299 12.299 0 0 1-1.873.892.077.077 0 0 0-.041.107c.36.698.772 1.362 1.225 1.993a.076.076 0 0 0 .084.028 19.839 19.839 0 0 0 6.002-3.03.077.077 0 0 0 .032-.054c.5-5.177-.838-9.674-3.549-13.66a.061.061 0 0 0-.031-.03zM8.02 15.33c-1.183 0-2.157-1.085-2.157-2.419 0-1.333.956-2.419 2.157-2.419 1.21 0 2.176 1.095 2.157 2.42 0 1.333-.956 2.418-2.157 2.418zm7.975 0c-1.183 0-2.157-1.085-2.157-2.419 0-1.333.955-2.419 2.157-2.419 1.21 0 2.176 1.095 2.157 2.42 0 1.333-.946 2.418-2.157 2.418z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
type SidebarFooterProps = {
|
||||
updateAvailable: boolean;
|
||||
releaseInfo: ReleaseInfo | null;
|
||||
@@ -69,9 +79,22 @@ export default function SidebarFooter({
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Settings */}
|
||||
{/* Discord + Settings */}
|
||||
<div className="nav-divider" />
|
||||
|
||||
{/* Desktop Discord */}
|
||||
<div className="hidden md:block px-2 pt-1.5">
|
||||
<a
|
||||
href={DISCORD_INVITE_URL}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="w-full flex items-center gap-2 px-2.5 py-1.5 rounded-lg text-muted-foreground hover:text-foreground hover:bg-accent/60 transition-colors"
|
||||
>
|
||||
<DiscordIcon className="w-3.5 h-3.5" />
|
||||
<span className="text-sm">{t('actions.joinCommunity')}</span>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
{/* Desktop settings */}
|
||||
<div className="hidden md:block px-2 py-1.5">
|
||||
<button
|
||||
@@ -83,8 +106,23 @@ export default function SidebarFooter({
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Mobile Discord */}
|
||||
<div className="md:hidden px-3 pt-3">
|
||||
<a
|
||||
href={DISCORD_INVITE_URL}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="w-full h-12 bg-muted/40 hover:bg-muted/60 rounded-xl flex items-center gap-3.5 px-4 active:scale-[0.98] transition-all"
|
||||
>
|
||||
<div className="w-8 h-8 rounded-xl bg-background/80 flex items-center justify-center">
|
||||
<DiscordIcon className="w-4.5 h-4.5 text-muted-foreground" />
|
||||
</div>
|
||||
<span className="text-base font-medium text-foreground">{t('actions.joinCommunity')}</span>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
{/* Mobile settings */}
|
||||
<div className="md:hidden p-3 pb-20">
|
||||
<div className="md:hidden px-3 pt-2 pb-20">
|
||||
<button
|
||||
className="w-full h-12 bg-muted/40 hover:bg-muted/60 rounded-xl flex items-center gap-3.5 px-4 active:scale-[0.98] transition-all"
|
||||
onClick={onShowSettings}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { useEffect } from 'react';
|
||||
import type { TFunction } from 'i18next';
|
||||
import type { LoadingProgress, Project, ProjectSession, SessionProvider } from '../../../../types/app';
|
||||
import type {
|
||||
@@ -103,6 +104,15 @@ export default function SidebarProjectList({
|
||||
/>
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
let baseTitle = 'CloudCLI UI';
|
||||
const displayName = selectedProject?.displayName?.trim();
|
||||
if (displayName) {
|
||||
baseTitle = `${displayName} - ${baseTitle}`;
|
||||
}
|
||||
document.title = baseTitle;
|
||||
}, [selectedProject]);
|
||||
|
||||
const showProjects = !isLoading && projects.length > 0 && filteredProjects.length > 0;
|
||||
|
||||
return (
|
||||
|
||||
103
src/hooks/useWebPush.ts
Normal file
103
src/hooks/useWebPush.ts
Normal file
@@ -0,0 +1,103 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { authenticatedFetch } from '../utils/api';
|
||||
|
||||
type WebPushState = {
|
||||
permission: NotificationPermission | 'unsupported';
|
||||
isSubscribed: boolean;
|
||||
isLoading: boolean;
|
||||
subscribe: () => Promise<void>;
|
||||
unsubscribe: () => Promise<void>;
|
||||
};
|
||||
|
||||
function urlBase64ToUint8Array(base64String: string): Uint8Array {
|
||||
const padding = '='.repeat((4 - (base64String.length % 4)) % 4);
|
||||
const base64 = (base64String + padding).replace(/-/g, '+').replace(/_/g, '/');
|
||||
const rawData = window.atob(base64);
|
||||
const outputArray = new Uint8Array(rawData.length);
|
||||
for (let i = 0; i < rawData.length; ++i) {
|
||||
outputArray[i] = rawData.charCodeAt(i);
|
||||
}
|
||||
return outputArray;
|
||||
}
|
||||
|
||||
export function useWebPush(): WebPushState {
|
||||
const [permission, setPermission] = useState<NotificationPermission | 'unsupported'>(() => {
|
||||
if (typeof window === 'undefined' || !('Notification' in window) || !('serviceWorker' in navigator)) {
|
||||
return 'unsupported';
|
||||
}
|
||||
return Notification.permission;
|
||||
});
|
||||
const [isSubscribed, setIsSubscribed] = useState(false);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
// Check existing subscription on mount
|
||||
useEffect(() => {
|
||||
if (permission === 'unsupported') return;
|
||||
|
||||
navigator.serviceWorker.ready.then((registration) => {
|
||||
registration.pushManager.getSubscription().then((sub) => {
|
||||
setIsSubscribed(sub !== null);
|
||||
});
|
||||
}).catch(() => {
|
||||
// SW not ready yet
|
||||
});
|
||||
}, [permission]);
|
||||
|
||||
const subscribe = useCallback(async () => {
|
||||
if (permission === 'unsupported') return;
|
||||
setIsLoading(true);
|
||||
|
||||
try {
|
||||
const perm = await Notification.requestPermission();
|
||||
setPermission(perm);
|
||||
if (perm !== 'granted') return;
|
||||
|
||||
const keyRes = await authenticatedFetch('/api/settings/push/vapid-public-key');
|
||||
const { publicKey } = await keyRes.json();
|
||||
|
||||
const registration = await navigator.serviceWorker.ready;
|
||||
const subscription = await registration.pushManager.subscribe({
|
||||
userVisibleOnly: true,
|
||||
applicationServerKey: urlBase64ToUint8Array(publicKey).buffer as ArrayBuffer,
|
||||
});
|
||||
|
||||
const subJson = subscription.toJSON();
|
||||
await authenticatedFetch('/api/settings/push/subscribe', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
endpoint: subJson.endpoint,
|
||||
keys: subJson.keys,
|
||||
}),
|
||||
});
|
||||
|
||||
setIsSubscribed(true);
|
||||
} catch (err) {
|
||||
console.error('Push subscribe failed:', err);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [permission]);
|
||||
|
||||
const unsubscribe = useCallback(async () => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const registration = await navigator.serviceWorker.ready;
|
||||
const subscription = await registration.pushManager.getSubscription();
|
||||
if (subscription) {
|
||||
const endpoint = subscription.endpoint;
|
||||
await subscription.unsubscribe();
|
||||
await authenticatedFetch('/api/settings/push/unsubscribe', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ endpoint }),
|
||||
});
|
||||
}
|
||||
setIsSubscribed(false);
|
||||
} catch (err) {
|
||||
console.error('Push unsubscribe failed:', err);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
return { permission, isSubscribed, isLoading, subscribe, unsubscribe };
|
||||
}
|
||||
@@ -28,5 +28,9 @@
|
||||
"lines": "Lines:",
|
||||
"characters": "Characters:",
|
||||
"shortcuts": "Press Ctrl+S to save • Esc to close"
|
||||
},
|
||||
"binaryFile": {
|
||||
"title": "Binary File",
|
||||
"message": "The file \"{{fileName}}\" cannot be displayed in the text editor because it is a binary file."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -114,7 +114,11 @@
|
||||
"justNow": "just now",
|
||||
"minAgo": "{{count}} min ago",
|
||||
"hoursAgo": "{{count}} hours ago",
|
||||
"daysAgo": "{{count}} days ago"
|
||||
"daysAgo": "{{count}} days ago",
|
||||
"newFile": "New File (Cmd+N)",
|
||||
"newFolder": "New Folder (Cmd+Shift+N)",
|
||||
"refresh": "Refresh",
|
||||
"collapseAll": "Collapse All"
|
||||
},
|
||||
"projectWizard": {
|
||||
"title": "Create New Project",
|
||||
@@ -191,6 +195,36 @@
|
||||
"failedToCreateFolder": "Failed to create folder"
|
||||
}
|
||||
},
|
||||
"notifications": {
|
||||
"genericTool": "a tool",
|
||||
"codes": {
|
||||
"generic": {
|
||||
"info": {
|
||||
"title": "Notification"
|
||||
}
|
||||
},
|
||||
"permission": {
|
||||
"required": {
|
||||
"title": "Action Required",
|
||||
"body": "{{toolName}} is waiting for your decision."
|
||||
}
|
||||
},
|
||||
"run": {
|
||||
"stopped": {
|
||||
"title": "Run Stopped",
|
||||
"body": "Reason: {{reason}}"
|
||||
},
|
||||
"failed": {
|
||||
"title": "Run Failed"
|
||||
}
|
||||
},
|
||||
"agent": {
|
||||
"notification": {
|
||||
"title": "Agent Notification"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"versionUpdate": {
|
||||
"title": "Update Available",
|
||||
"newVersionReady": "A new version is ready",
|
||||
|
||||
@@ -84,11 +84,32 @@
|
||||
}
|
||||
},
|
||||
"mainTabs": {
|
||||
"label": "Settings",
|
||||
"agents": "Agents",
|
||||
"appearance": "Appearance",
|
||||
"git": "Git",
|
||||
"apiTokens": "API & Tokens",
|
||||
"tasks": "Tasks"
|
||||
"tasks": "Tasks",
|
||||
"notifications": "Notifications"
|
||||
},
|
||||
"notifications": {
|
||||
"title": "Notifications",
|
||||
"description": "Control which notification events you receive.",
|
||||
"webPush": {
|
||||
"title": "Web Push Notifications",
|
||||
"enable": "Enable Push Notifications",
|
||||
"disable": "Disable Push Notifications",
|
||||
"enabled": "Push notifications are enabled",
|
||||
"loading": "Updating...",
|
||||
"unsupported": "Push notifications are not supported in this browser.",
|
||||
"denied": "Push notifications are blocked. Please allow them in your browser settings."
|
||||
},
|
||||
"events": {
|
||||
"title": "Event Types",
|
||||
"actionRequired": "Action required",
|
||||
"stop": "Run stopped",
|
||||
"error": "Run failed"
|
||||
}
|
||||
},
|
||||
"appearanceSettings": {
|
||||
"darkMode": {
|
||||
|
||||
@@ -63,7 +63,8 @@
|
||||
"cancel": "Cancel",
|
||||
"save": "Save",
|
||||
"delete": "Delete",
|
||||
"rename": "Rename"
|
||||
"rename": "Rename",
|
||||
"joinCommunity": "Join Community"
|
||||
},
|
||||
"status": {
|
||||
"active": "Active",
|
||||
|
||||
@@ -26,5 +26,9 @@
|
||||
"lines": "行数:",
|
||||
"characters": "文字数:",
|
||||
"shortcuts": "Ctrl+Sで保存 • Escで閉じる"
|
||||
},
|
||||
"binaryFile": {
|
||||
"title": "バイナリファイル",
|
||||
"message": "ファイル \"{{fileName}}\" はバイナリファイルのため、テキストエディタで表示できません。"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -114,7 +114,11 @@
|
||||
"justNow": "たった今",
|
||||
"minAgo": "{{count}}分前",
|
||||
"hoursAgo": "{{count}}時間前",
|
||||
"daysAgo": "{{count}}日前"
|
||||
"daysAgo": "{{count}}日前",
|
||||
"newFile": "新規ファイル (Cmd+N)",
|
||||
"newFolder": "新規フォルダ (Cmd+Shift+N)",
|
||||
"refresh": "更新",
|
||||
"collapseAll": "すべて折りたたむ"
|
||||
},
|
||||
"projectWizard": {
|
||||
"title": "新規プロジェクトを作成",
|
||||
@@ -191,6 +195,36 @@
|
||||
"failedToCreateFolder": "フォルダの作成に失敗しました"
|
||||
}
|
||||
},
|
||||
"notifications": {
|
||||
"genericTool": "ツール",
|
||||
"codes": {
|
||||
"generic": {
|
||||
"info": {
|
||||
"title": "通知"
|
||||
}
|
||||
},
|
||||
"permission": {
|
||||
"required": {
|
||||
"title": "対応が必要です",
|
||||
"body": "{{toolName}} があなたの判断を待っています。"
|
||||
}
|
||||
},
|
||||
"run": {
|
||||
"stopped": {
|
||||
"title": "実行が停止しました",
|
||||
"body": "理由: {{reason}}"
|
||||
},
|
||||
"failed": {
|
||||
"title": "実行に失敗しました"
|
||||
}
|
||||
},
|
||||
"agent": {
|
||||
"notification": {
|
||||
"title": "エージェント通知"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"versionUpdate": {
|
||||
"title": "アップデートのお知らせ",
|
||||
"newVersionReady": "新しいバージョンが利用可能です",
|
||||
|
||||
@@ -84,11 +84,32 @@
|
||||
}
|
||||
},
|
||||
"mainTabs": {
|
||||
"label": "設定",
|
||||
"agents": "エージェント",
|
||||
"appearance": "外観",
|
||||
"git": "Git",
|
||||
"apiTokens": "API & トークン",
|
||||
"tasks": "タスク"
|
||||
"tasks": "タスク",
|
||||
"notifications": "通知"
|
||||
},
|
||||
"notifications": {
|
||||
"title": "通知",
|
||||
"description": "受信する通知イベントを設定します。",
|
||||
"webPush": {
|
||||
"title": "Webプッシュ通知",
|
||||
"enable": "プッシュ通知を有効にする",
|
||||
"disable": "プッシュ通知を無効にする",
|
||||
"enabled": "プッシュ通知は有効です",
|
||||
"loading": "更新中...",
|
||||
"unsupported": "このブラウザではプッシュ通知がサポートされていません。",
|
||||
"denied": "プッシュ通知がブロックされています。ブラウザの設定で許可してください。"
|
||||
},
|
||||
"events": {
|
||||
"title": "イベント種別",
|
||||
"actionRequired": "対応が必要",
|
||||
"stop": "実行停止",
|
||||
"error": "実行失敗"
|
||||
}
|
||||
},
|
||||
"appearanceSettings": {
|
||||
"darkMode": {
|
||||
|
||||
@@ -63,7 +63,8 @@
|
||||
"cancel": "キャンセル",
|
||||
"save": "保存",
|
||||
"delete": "削除",
|
||||
"rename": "名前の変更"
|
||||
"rename": "名前の変更",
|
||||
"joinCommunity": "コミュニティに参加"
|
||||
},
|
||||
"status": {
|
||||
"active": "アクティブ",
|
||||
|
||||
@@ -26,5 +26,9 @@
|
||||
"lines": "줄:",
|
||||
"characters": "문자:",
|
||||
"shortcuts": "Ctrl+S로 저장 • Esc로 닫기"
|
||||
},
|
||||
"binaryFile": {
|
||||
"title": "바이너리 파일",
|
||||
"message": "파일 \"{{fileName}}\"은(는) 바이너리 파일이므로 텍스트 편집기에서 표시할 수 없습니다."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -114,7 +114,11 @@
|
||||
"justNow": "방금 전",
|
||||
"minAgo": "{{count}}분 전",
|
||||
"hoursAgo": "{{count}}시간 전",
|
||||
"daysAgo": "{{count}}일 전"
|
||||
"daysAgo": "{{count}}일 전",
|
||||
"newFile": "새 파일 (Cmd+N)",
|
||||
"newFolder": "새 폴더 (Cmd+Shift+N)",
|
||||
"refresh": "새로고침",
|
||||
"collapseAll": "모두 접기"
|
||||
},
|
||||
"projectWizard": {
|
||||
"title": "새 프로젝트 생성",
|
||||
@@ -191,6 +195,36 @@
|
||||
"failedToCreateFolder": "폴더 생성 실패"
|
||||
}
|
||||
},
|
||||
"notifications": {
|
||||
"genericTool": "도구",
|
||||
"codes": {
|
||||
"generic": {
|
||||
"info": {
|
||||
"title": "알림"
|
||||
}
|
||||
},
|
||||
"permission": {
|
||||
"required": {
|
||||
"title": "작업 필요",
|
||||
"body": "{{toolName}} 에 대한 결정을 기다리고 있습니다."
|
||||
}
|
||||
},
|
||||
"run": {
|
||||
"stopped": {
|
||||
"title": "실행이 중지되었습니다",
|
||||
"body": "사유: {{reason}}"
|
||||
},
|
||||
"failed": {
|
||||
"title": "실행 실패"
|
||||
}
|
||||
},
|
||||
"agent": {
|
||||
"notification": {
|
||||
"title": "에이전트 알림"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"versionUpdate": {
|
||||
"title": "업데이트 가능",
|
||||
"newVersionReady": "새 버전이 준비되었습니다",
|
||||
|
||||
@@ -84,11 +84,32 @@
|
||||
}
|
||||
},
|
||||
"mainTabs": {
|
||||
"label": "설정",
|
||||
"agents": "에이전트",
|
||||
"appearance": "외관",
|
||||
"git": "Git",
|
||||
"apiTokens": "API & 토큰",
|
||||
"tasks": "작업"
|
||||
"tasks": "작업",
|
||||
"notifications": "알림"
|
||||
},
|
||||
"notifications": {
|
||||
"title": "알림",
|
||||
"description": "수신할 알림 이벤트를 설정합니다.",
|
||||
"webPush": {
|
||||
"title": "웹 푸시 알림",
|
||||
"enable": "푸시 알림 활성화",
|
||||
"disable": "푸시 알림 비활성화",
|
||||
"enabled": "푸시 알림이 활성화되었습니다",
|
||||
"loading": "업데이트 중...",
|
||||
"unsupported": "이 브라우저에서는 푸시 알림이 지원되지 않습니다.",
|
||||
"denied": "푸시 알림이 차단되었습니다. 브라우저 설정에서 허용해 주세요."
|
||||
},
|
||||
"events": {
|
||||
"title": "이벤트 유형",
|
||||
"actionRequired": "작업 필요",
|
||||
"stop": "실행 중지",
|
||||
"error": "실행 실패"
|
||||
}
|
||||
},
|
||||
"appearanceSettings": {
|
||||
"darkMode": {
|
||||
|
||||
@@ -63,7 +63,8 @@
|
||||
"cancel": "취소",
|
||||
"save": "저장",
|
||||
"delete": "삭제",
|
||||
"rename": "이름 변경"
|
||||
"rename": "이름 변경",
|
||||
"joinCommunity": "커뮤니티 참여"
|
||||
},
|
||||
"status": {
|
||||
"active": "활성",
|
||||
|
||||
@@ -26,5 +26,9 @@
|
||||
"lines": "行数:",
|
||||
"characters": "字符数:",
|
||||
"shortcuts": "按 Ctrl+S 保存 • Esc 关闭"
|
||||
},
|
||||
"binaryFile": {
|
||||
"title": "二进制文件",
|
||||
"message": "文件 \"{{fileName}}\" 无法在文本编辑器中显示,因为它是二进制文件。"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -114,7 +114,11 @@
|
||||
"justNow": "刚刚",
|
||||
"minAgo": "{{count}} 分钟前",
|
||||
"hoursAgo": "{{count}} 小时前",
|
||||
"daysAgo": "{{count}} 天前"
|
||||
"daysAgo": "{{count}} 天前",
|
||||
"newFile": "新建文件 (Cmd+N)",
|
||||
"newFolder": "新建文件夹 (Cmd+Shift+N)",
|
||||
"refresh": "刷新",
|
||||
"collapseAll": "全部折叠"
|
||||
},
|
||||
"projectWizard": {
|
||||
"title": "创建新项目",
|
||||
@@ -191,6 +195,36 @@
|
||||
"failedToCreateFolder": "创建文件夹失败"
|
||||
}
|
||||
},
|
||||
"notifications": {
|
||||
"genericTool": "工具",
|
||||
"codes": {
|
||||
"generic": {
|
||||
"info": {
|
||||
"title": "通知"
|
||||
}
|
||||
},
|
||||
"permission": {
|
||||
"required": {
|
||||
"title": "需要处理",
|
||||
"body": "{{toolName}} 正在等待你的决策。"
|
||||
}
|
||||
},
|
||||
"run": {
|
||||
"stopped": {
|
||||
"title": "运行已停止",
|
||||
"body": "原因:{{reason}}"
|
||||
},
|
||||
"failed": {
|
||||
"title": "运行失败"
|
||||
}
|
||||
},
|
||||
"agent": {
|
||||
"notification": {
|
||||
"title": "Agent 通知"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"versionUpdate": {
|
||||
"title": "有可用更新",
|
||||
"newVersionReady": "新版本已准备就绪",
|
||||
|
||||
@@ -84,11 +84,32 @@
|
||||
}
|
||||
},
|
||||
"mainTabs": {
|
||||
"label": "设置",
|
||||
"agents": "智能体",
|
||||
"appearance": "外观",
|
||||
"git": "Git",
|
||||
"apiTokens": "API 和令牌",
|
||||
"tasks": "任务"
|
||||
"tasks": "任务",
|
||||
"notifications": "通知"
|
||||
},
|
||||
"notifications": {
|
||||
"title": "通知",
|
||||
"description": "控制你希望接收的通知事件。",
|
||||
"webPush": {
|
||||
"title": "Web 推送通知",
|
||||
"enable": "启用推送通知",
|
||||
"disable": "关闭推送通知",
|
||||
"enabled": "推送通知已启用",
|
||||
"loading": "更新中...",
|
||||
"unsupported": "此浏览器不支持推送通知。",
|
||||
"denied": "推送通知已被阻止,请在浏览器设置中允许。"
|
||||
},
|
||||
"events": {
|
||||
"title": "事件类型",
|
||||
"actionRequired": "需要处理",
|
||||
"stop": "运行已停止",
|
||||
"error": "运行失败"
|
||||
}
|
||||
},
|
||||
"appearanceSettings": {
|
||||
"darkMode": {
|
||||
|
||||
@@ -63,7 +63,8 @@
|
||||
"cancel": "取消",
|
||||
"save": "保存",
|
||||
"delete": "删除",
|
||||
"rename": "重命名"
|
||||
"rename": "重命名",
|
||||
"joinCommunity": "加入社区"
|
||||
},
|
||||
"status": {
|
||||
"active": "活动",
|
||||
|
||||
10
src/main.jsx
10
src/main.jsx
@@ -7,14 +7,10 @@ import 'katex/dist/katex.min.css'
|
||||
// Initialize i18n
|
||||
import './i18n/config.js'
|
||||
|
||||
// Clean up stale service workers on app load to prevent caching issues after builds
|
||||
// Register service worker for PWA + Web Push support
|
||||
if ('serviceWorker' in navigator) {
|
||||
navigator.serviceWorker.getRegistrations().then(registrations => {
|
||||
registrations.forEach(registration => {
|
||||
registration.unregister();
|
||||
});
|
||||
}).catch(err => {
|
||||
console.warn('Failed to unregister service workers:', err);
|
||||
navigator.serviceWorker.register('/sw.js').catch(err => {
|
||||
console.warn('Service worker registration failed:', err);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -108,6 +108,33 @@ export const api = {
|
||||
}),
|
||||
getFiles: (projectName, options = {}) =>
|
||||
authenticatedFetch(`/api/projects/${projectName}/files`, options),
|
||||
|
||||
// File operations
|
||||
createFile: (projectName, { path, type, name }) =>
|
||||
authenticatedFetch(`/api/projects/${projectName}/files/create`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ path, type, name }),
|
||||
}),
|
||||
|
||||
renameFile: (projectName, { oldPath, newName }) =>
|
||||
authenticatedFetch(`/api/projects/${projectName}/files/rename`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({ oldPath, newName }),
|
||||
}),
|
||||
|
||||
deleteFile: (projectName, { path, type }) =>
|
||||
authenticatedFetch(`/api/projects/${projectName}/files`, {
|
||||
method: 'DELETE',
|
||||
body: JSON.stringify({ path, type }),
|
||||
}),
|
||||
|
||||
uploadFiles: (projectName, formData) =>
|
||||
authenticatedFetch(`/api/projects/${projectName}/files/upload`, {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
headers: {}, // Let browser set Content-Type for FormData
|
||||
}),
|
||||
|
||||
transcribe: (formData) =>
|
||||
authenticatedFetch('/api/transcribe', {
|
||||
method: 'POST',
|
||||
@@ -187,4 +214,22 @@ export const api = {
|
||||
|
||||
// Generic GET method for any endpoint
|
||||
get: (endpoint) => authenticatedFetch(`/api${endpoint}`),
|
||||
|
||||
// Generic POST method for any endpoint
|
||||
post: (endpoint, body) => authenticatedFetch(`/api${endpoint}`, {
|
||||
method: 'POST',
|
||||
...(body instanceof FormData ? { body } : { body: JSON.stringify(body) }),
|
||||
}),
|
||||
|
||||
// Generic PUT method for any endpoint
|
||||
put: (endpoint, body) => authenticatedFetch(`/api${endpoint}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(body),
|
||||
}),
|
||||
|
||||
// Generic DELETE method for any endpoint
|
||||
delete: (endpoint, options = {}) => authenticatedFetch(`/api${endpoint}`, {
|
||||
method: 'DELETE',
|
||||
...options,
|
||||
}),
|
||||
};
|
||||
Reference in New Issue
Block a user