mirror of
https://github.com/siteboon/claudecodeui.git
synced 2026-03-10 08:27:40 +00:00
Compare commits
10 Commits
bc52d9ea28
...
6be7045f17
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6be7045f17 | ||
|
|
d032a37c01 | ||
|
|
964d8e3231 | ||
|
|
84d4634735 | ||
|
|
14d17ae104 | ||
|
|
5d55e65727 | ||
|
|
909ff05118 | ||
|
|
b5bbf11524 | ||
|
|
855e22f917 | ||
|
|
97689588aa |
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
|
||||
|
||||
34
README.md
34
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), [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.
|
||||
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
|
||||
@@ -56,7 +64,15 @@ A desktop and mobile UI for [Claude Code](https://docs.anthropic.com/en/docs/cla
|
||||
|
||||
## 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
|
||||
@@ -64,7 +80,7 @@ A desktop and mobile UI for [Claude Code](https://docs.anthropic.com/en/docs/cla
|
||||
- [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:
|
||||
|
||||
@@ -121,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
|
||||
|
||||
@@ -145,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
|
||||
@@ -209,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)
|
||||
@@ -338,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
|
||||
|
||||
117
package-lock.json
generated
117
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",
|
||||
@@ -169,7 +170,6 @@
|
||||
"integrity": "sha512-UlLAnTPrFdNGoFtbSXwcGFQBtQZJCNjaN6hQNP3UPvuNXT1i82N26KL3dZeIpNalWywr9IuQuncaAfUaS1g6sQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@ampproject/remapping": "^2.2.0",
|
||||
"@babel/code-frame": "^7.27.1",
|
||||
@@ -542,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",
|
||||
@@ -557,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",
|
||||
@@ -593,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"
|
||||
}
|
||||
@@ -615,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",
|
||||
@@ -2038,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",
|
||||
@@ -2057,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"
|
||||
}
|
||||
@@ -2268,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",
|
||||
@@ -3189,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"
|
||||
@@ -3334,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",
|
||||
@@ -3853,7 +3844,6 @@
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"caniuse-lite": "^1.0.30001726",
|
||||
"electron-to-chromium": "^1.5.173",
|
||||
@@ -4715,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",
|
||||
@@ -6450,7 +6446,6 @@
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.28.4"
|
||||
},
|
||||
@@ -6504,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",
|
||||
@@ -6888,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",
|
||||
@@ -7020,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",
|
||||
@@ -7075,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",
|
||||
@@ -9150,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",
|
||||
@@ -9367,7 +9431,6 @@
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"nanoid": "^3.3.11",
|
||||
"picocolors": "^1.1.1",
|
||||
@@ -9550,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",
|
||||
@@ -9749,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"
|
||||
},
|
||||
@@ -9762,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"
|
||||
@@ -10196,7 +10263,6 @@
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@nodeutils/defaults-deep": "1.1.0",
|
||||
"@octokit/rest": "22.0.0",
|
||||
@@ -10688,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",
|
||||
@@ -11932,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",
|
||||
@@ -12176,7 +12247,6 @@
|
||||
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
@@ -12324,7 +12394,6 @@
|
||||
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
||||
"devOptional": true,
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"tsc": "bin/tsc",
|
||||
"tsserver": "bin/tsserver"
|
||||
@@ -12653,7 +12722,6 @@
|
||||
"integrity": "sha512-oBXvfSHEOL8jF+R9Am7h59Up07kVVGH1NrFGFoEG6bPDZP3tGpQhvkBpy5x7U6+E6wZCu9OihsWgJqDbQIm8LQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"esbuild": "^0.25.0",
|
||||
"fdir": "^6.5.0",
|
||||
@@ -12747,7 +12815,6 @@
|
||||
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -19,6 +19,11 @@ 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 => {
|
||||
@@ -60,8 +65,8 @@ self.addEventListener('push', event => {
|
||||
|
||||
const options = {
|
||||
body: payload.body || '',
|
||||
icon: '/logo.png',
|
||||
badge: '/logo.png',
|
||||
icon: '/logo-256.png',
|
||||
badge: '/logo-128.png',
|
||||
data: payload.data || {},
|
||||
tag: payload.data?.code || 'default',
|
||||
renotify: true
|
||||
|
||||
@@ -381,7 +381,7 @@ const credentialsDb = {
|
||||
const DEFAULT_NOTIFICATION_PREFERENCES = {
|
||||
channels: {
|
||||
inApp: false,
|
||||
webPush: true
|
||||
webPush: false
|
||||
},
|
||||
events: {
|
||||
actionRequired: true,
|
||||
@@ -396,7 +396,7 @@ const normalizeNotificationPreferences = (value) => {
|
||||
return {
|
||||
channels: {
|
||||
inApp: source.channels?.inApp === true,
|
||||
webPush: source.channels?.webPush !== false
|
||||
webPush: source.channels?.webPush === true
|
||||
},
|
||||
events: {
|
||||
actionRequired: source.events?.actionRequired !== false,
|
||||
|
||||
430
server/index.js
430
server/index.js
@@ -885,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;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import express from 'express';
|
||||
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();
|
||||
|
||||
@@ -221,7 +222,27 @@ router.post('/push/subscribe', async (req, res) => {
|
||||
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' });
|
||||
@@ -235,6 +256,16 @@ router.post('/push/unsubscribe', async (req, res) => {
|
||||
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);
|
||||
|
||||
@@ -62,30 +62,18 @@ function createNotificationEvent({
|
||||
|
||||
function buildPushBody(event) {
|
||||
const CODE_MAP = {
|
||||
'permission.required': {
|
||||
title: 'Action Required',
|
||||
body: event.meta?.toolName
|
||||
? `Tool "${event.meta.toolName}" needs approval`
|
||||
: 'A tool needs your approval'
|
||||
},
|
||||
'run.stopped': {
|
||||
title: 'Run Stopped',
|
||||
body: event.meta?.stopReason || 'The run has stopped'
|
||||
},
|
||||
'run.failed': {
|
||||
title: 'Run Failed',
|
||||
body: event.meta?.error ? String(event.meta.error) : 'The run encountered an error'
|
||||
},
|
||||
'agent.notification': {
|
||||
title: 'Agent Notification',
|
||||
body: event.meta?.message ? String(event.meta.message) : 'You have a new notification'
|
||||
}
|
||||
'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!'
|
||||
};
|
||||
|
||||
const mapped = CODE_MAP[event.code];
|
||||
return {
|
||||
title: mapped?.title || 'Claude Code UI',
|
||||
body: mapped?.body || 'You have a new notification',
|
||||
title: 'Claude Code UI',
|
||||
body: CODE_MAP[event.code] || 'You have a new notification',
|
||||
data: {
|
||||
sessionId: event.sessionId || null,
|
||||
code: event.code
|
||||
|
||||
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
|
||||
|
||||
@@ -96,6 +96,7 @@ function Settings({ isOpen, onClose, projects = [], initialTab = 'agents' }: Set
|
||||
|
||||
const handleEnablePush = async () => {
|
||||
await pushSubscribe();
|
||||
// Server sets webPush: true in preferences on subscribe; sync local state
|
||||
setNotificationPreferences({
|
||||
...notificationPreferences,
|
||||
channels: { ...notificationPreferences.channels, webPush: true },
|
||||
@@ -104,6 +105,7 @@ function Settings({ isOpen, onClose, projects = [], initialTab = 'agents' }: Set
|
||||
|
||||
const handleDisablePush = async () => {
|
||||
await pushUnsubscribe();
|
||||
// Server sets webPush: false in preferences on unsubscribe; sync local state
|
||||
setNotificationPreferences({
|
||||
...notificationPreferences,
|
||||
channels: { ...notificationPreferences.channels, webPush: false },
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Bell } from 'lucide-react';
|
||||
import { Bell, BellOff, BellRing, Loader2 } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import type { NotificationPreferencesState } from '../../types/types';
|
||||
|
||||
@@ -43,26 +43,42 @@ export default function NotificationsSettingsTab({
|
||||
) : pushDenied ? (
|
||||
<p className="text-sm text-muted-foreground">{t('notifications.webPush.denied')}</p>
|
||||
) : (
|
||||
<label className="flex items-center gap-2 text-sm text-foreground">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={isPushSubscribed}
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
type="button"
|
||||
disabled={isPushLoading}
|
||||
onChange={() => {
|
||||
onClick={() => {
|
||||
if (isPushSubscribed) {
|
||||
onDisablePush();
|
||||
} else {
|
||||
onEnablePush();
|
||||
}
|
||||
}}
|
||||
className="w-4 h-4"
|
||||
/>
|
||||
{isPushLoading
|
||||
? t('notifications.webPush.loading')
|
||||
: isPushSubscribed
|
||||
? t('notifications.webPush.enabled')
|
||||
: t('notifications.webPush.disabled')}
|
||||
</label>
|
||||
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>
|
||||
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -84,6 +84,7 @@
|
||||
}
|
||||
},
|
||||
"mainTabs": {
|
||||
"label": "Settings",
|
||||
"agents": "Agents",
|
||||
"appearance": "Appearance",
|
||||
"git": "Git",
|
||||
@@ -96,8 +97,9 @@
|
||||
"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",
|
||||
"disabled": "Enable push notifications",
|
||||
"loading": "Updating...",
|
||||
"unsupported": "Push notifications are not supported in this browser.",
|
||||
"denied": "Push notifications are blocked. Please allow them in your browser settings."
|
||||
|
||||
@@ -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": "新規プロジェクトを作成",
|
||||
|
||||
@@ -84,6 +84,7 @@
|
||||
}
|
||||
},
|
||||
"mainTabs": {
|
||||
"label": "設定",
|
||||
"agents": "エージェント",
|
||||
"appearance": "外観",
|
||||
"git": "Git",
|
||||
@@ -96,8 +97,9 @@
|
||||
"description": "受信する通知イベントを設定します。",
|
||||
"webPush": {
|
||||
"title": "Webプッシュ通知",
|
||||
"enable": "プッシュ通知を有効にする",
|
||||
"disable": "プッシュ通知を無効にする",
|
||||
"enabled": "プッシュ通知は有効です",
|
||||
"disabled": "プッシュ通知を有効にする",
|
||||
"loading": "更新中...",
|
||||
"unsupported": "このブラウザではプッシュ通知がサポートされていません。",
|
||||
"denied": "プッシュ通知がブロックされています。ブラウザの設定で許可してください。"
|
||||
|
||||
@@ -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": "새 프로젝트 생성",
|
||||
|
||||
@@ -84,6 +84,7 @@
|
||||
}
|
||||
},
|
||||
"mainTabs": {
|
||||
"label": "설정",
|
||||
"agents": "에이전트",
|
||||
"appearance": "외관",
|
||||
"git": "Git",
|
||||
@@ -96,8 +97,9 @@
|
||||
"description": "수신할 알림 이벤트를 설정합니다.",
|
||||
"webPush": {
|
||||
"title": "웹 푸시 알림",
|
||||
"enable": "푸시 알림 활성화",
|
||||
"disable": "푸시 알림 비활성화",
|
||||
"enabled": "푸시 알림이 활성화되었습니다",
|
||||
"disabled": "푸시 알림 활성화",
|
||||
"loading": "업데이트 중...",
|
||||
"unsupported": "이 브라우저에서는 푸시 알림이 지원되지 않습니다.",
|
||||
"denied": "푸시 알림이 차단되었습니다. 브라우저 설정에서 허용해 주세요."
|
||||
|
||||
@@ -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": "创建新项目",
|
||||
|
||||
@@ -84,6 +84,7 @@
|
||||
}
|
||||
},
|
||||
"mainTabs": {
|
||||
"label": "设置",
|
||||
"agents": "智能体",
|
||||
"appearance": "外观",
|
||||
"git": "Git",
|
||||
@@ -96,8 +97,9 @@
|
||||
"description": "控制你希望接收的通知事件。",
|
||||
"webPush": {
|
||||
"title": "Web 推送通知",
|
||||
"enable": "启用推送通知",
|
||||
"disable": "关闭推送通知",
|
||||
"enabled": "推送通知已启用",
|
||||
"disabled": "启用推送通知",
|
||||
"loading": "更新中...",
|
||||
"unsupported": "此浏览器不支持推送通知。",
|
||||
"denied": "推送通知已被阻止,请在浏览器设置中允许。"
|
||||
|
||||
@@ -63,7 +63,8 @@
|
||||
"cancel": "取消",
|
||||
"save": "保存",
|
||||
"delete": "删除",
|
||||
"rename": "重命名"
|
||||
"rename": "重命名",
|
||||
"joinCommunity": "加入社区"
|
||||
},
|
||||
"status": {
|
||||
"active": "活动",
|
||||
|
||||
@@ -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