Compare commits

..

17 Commits

Author SHA1 Message Date
simosmik
6be7045f17 fix: notifications orchestrator and add a notification when first enabled 2026-03-03 16:28:12 +00:00
Simos Mikelatos
d032a37c01 Merge branch 'main' into feat/notifications 2026-03-03 15:51:08 +01:00
simosmik
964d8e3231 Release 1.22.0 2026-03-03 14:50:44 +00:00
simosmik
84d4634735 feat: add community button in the app 2026-03-03 14:44:08 +00:00
simosmik
14d17ae104 update readme with discord 2026-03-03 13:53:04 +00:00
simosmik
5d55e65727 default to false for webpush notifications and translations for the button 2026-03-03 13:30:19 +00:00
Simos Mikelatos
909ff05118 Merge branch 'main' into feat/notifications 2026-03-03 14:24:04 +01:00
simosmik
b5bbf11524 fix(sw): prevent caching of API requests and WebSocket upgrades 2026-03-03 13:23:24 +00:00
simosmik
855e22f917 fix: missing translation label 2026-03-03 13:18:08 +00:00
朱见
97689588aa feat: Advanced file editor and file tree improvements (#444)
# Features
- File drag and drop upload: Support uploading files and folders via drag and drop
- Binary file handling: Detect binary files and display a friendly message instead of trying to edit them
- Folder download: Download folders as ZIP files (using JSZip library)
- Context menu integration: Full right-click context menu for file operations (rename, delete, copy path, download, new file/folder)
2026-03-03 15:19:46 +03:00
viper151
bc52d9ea28 Merge branch 'main' into feat/notifications 2026-03-02 16:04:55 +01:00
Menny Even Danan
503c384685 chore: add Gemini-CLI support to README (#453) 2026-03-02 10:56:36 +03:00
louis-thorp-datacom
506d43144b fix(claude): move model usage log to result message only (#454)
The modelUsage debug log ran on every streamed SDK message, but
modelUsage is only populated on result messages. This produced
repeated "Model was sent using: []" console output for every
non-result message during streaming.

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-02 10:49:06 +03:00
Xì Gà
9e22f42a3d feat: update document title based on selected project (#448)
Show dynamic browser tab title based on selected project's name, post-fixed with "CloudCLI UI" when a project is selected, improving navigation across tabs.
2026-02-27 18:51:26 +03:00
Xì Gà
9c0e864532 fix(claude): correct project encoded path (#451)
fix wrong regex replace of Claude project path

related #447, reopen due to forced-push

to reproduce error steps, let's try

Create a folder with @ in name like @test
Add this folder as new project in CloudCLI
Choose Claude tool in new Session
Star by typing sth 'hi'
In the dev tools, you will see errors ajax response said that session does not find for 'some-session-id'

The main problem is current encode path doesn't encode '@' to '-' as Claude did
I reversed code Claude-SDK, file 'cli.js' to find exactly regex (using in PR) that used to encode path under ~/.claude/projects/<encoded-project-name-path/<session-id>.jsonl
2026-02-27 18:46:23 +03:00
simosmik
8339b8e624 Merge branch 'main' into feat/notifications
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-27 14:47:27 +00:00
simosmik
061f0fd297 feat: introduce notification system and claude notifications 2026-02-27 14:44:44 +00:00
60 changed files with 3651 additions and 181 deletions

View File

@@ -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

View File

@@ -1,12 +1,20 @@
<div align="center">
<img src="public/logo.svg" alt="Claude Code UI" width="64" height="64">
<img src="public/logo.svg" alt="CloudCLI UI" width="64" height="64">
<h1>Cloud CLI (aka Claude Code UI)</h1>
</div>
A desktop and mobile UI for [Claude Code](https://docs.anthropic.com/en/docs/claude-code), [Cursor CLI](https://docs.cursor.com/en/cli/overview) and [Codex](https://developers.openai.com/codex). You can use it locally or remotely to view your active projects and sessions in Claude Code, Cursor, or Codex and make changes to them from everywhere (mobile or desktop). This gives you a proper interface that works everywhere.
A desktop and mobile UI for [Claude Code](https://docs.anthropic.com/en/docs/claude-code), [Cursor CLI](https://docs.cursor.com/en/cli/overview), [Codex](https://developers.openai.com/codex), and [Gemini-CLI](https://geminicli.com/). You can use it locally or remotely to view your active projects and sessions and make changes to them from everywhere (mobile or desktop). This gives you a proper interface that works everywhere.
<p align="center">
<a href="https://cloudcli.ai">CloudCLI Cloud</a> · <a href="https://discord.gg/buxwujPNRE">Discord</a> · <a href="https://github.com/siteboon/claudecodeui/issues">Bug Reports</a> · <a href="CONTRIBUTING.md">Contributing</a>
</p>
<p align="center">
<a href="https://discord.gg/buxwujPNRE"><img src="https://img.shields.io/badge/Discord-Join%20Community-5865F2?logo=discord&logoColor=white" alt="Join our Discord"></a>
<a href="https://trendshift.io/repositories/15586" target="_blank"><img src="https://trendshift.io/api/badge/repositories/15586" alt="siteboon%2Fclaudecodeui | Trendshift" style="width: 250px; height: 55px;" width="250" height="55"/></a>
</p>
<a href="https://trendshift.io/repositories/15586" target="_blank"><img src="https://trendshift.io/api/badge/repositories/15586" alt="siteboon%2Fclaudecodeui | Trendshift" style="width: 250px; height: 55px;" width="250" height="55"/></a>
<div align="right"><i><b>English</b> · <a href="./README.ko.md">한국어</a> · <a href="./README.zh-CN.md">中文</a> · <a href="./README.ja.md">日本語</a></i></div>
## Screenshots
@@ -44,26 +52,35 @@ A desktop and mobile UI for [Claude Code](https://docs.anthropic.com/en/docs/cla
## Features
- **Responsive Design** - Works seamlessly across desktop, tablet, and mobile so you can also use Claude Code, Cursor, or Codex from mobile
- **Interactive Chat Interface** - Built-in chat interface for seamless communication with Claude Code, Cursor, or Codex
- **Integrated Shell Terminal** - Direct access to Claude Code, Cursor CLI, or Codex through built-in shell functionality
- **Responsive Design** - Works seamlessly across desktop, tablet, and mobile so you can also use Agents from mobile
- **Interactive Chat Interface** - Built-in chat interface for seamless communication with the Agents
- **Integrated Shell Terminal** - Direct access to the Agents CLI through built-in shell functionality
- **File Explorer** - Interactive file tree with syntax highlighting and live editing
- **Git Explorer** - View, stage and commit your changes. You can also switch branches
- **Session Management** - Resume conversations, manage multiple sessions, and track history
- **TaskMaster AI Integration** *(Optional)* - Advanced project management with AI-powered task planning, PRD parsing, and workflow automation
- **Model Compatibility** - Works with Claude Sonnet 4.5, Opus 4.5, and GPT-5.2
- **Model Compatibility** - Works with Claude Sonnet 4.5, Opus 4.5, GPT-5.2, and Gemini.
## Quick Start
### Prerequisites
### CloudCLI Cloud (Recommended)
The fastest way to get started — no local setup required. Get a fully managed, containerized development environment accessible from the web, mobile app, API, or your favorite IDE.
**[Get started with CloudCLI Cloud](https://cloudcli.ai)**
### Self-Hosted (Open Source)
#### Prerequisites
- [Node.js](https://nodejs.org/) v22 or higher
- [Claude Code CLI](https://docs.anthropic.com/en/docs/claude-code) installed and configured, and/or
- [Cursor CLI](https://docs.cursor.com/en/cli/overview) installed and configured, and/or
- [Codex](https://developers.openai.com/codex) installed and configured
- [Codex](https://developers.openai.com/codex) installed and configured, and/or
- [Gemini-CLI](https://geminicli.com/) installed and configured
### One-click Operation (Recommended)
#### One-click Operation
No installation required, direct operation:
@@ -120,7 +137,7 @@ cloudcli status # Show current configuration
### Run as Background Service (Recommended for Production)
For production use, run Claude Code UI as a background service using PM2 (Process Manager 2):
For production use, run CloudCLI as a background service using PM2 (Process Manager 2):
#### Install PM2
@@ -144,7 +161,7 @@ pm2 start cloudcli --name "claude-code-ui" -- --port 8080
#### Auto-Start on System Boot
To make Claude Code UI start automatically when your system boots:
To make CloudCLI UI start automatically when your system boots:
```bash
# Generate startup script for your platform
@@ -208,7 +225,7 @@ To use Claude Code's full functionality, you'll need to manually enable tools:
## TaskMaster AI Integration *(Optional)*
Claude Code UI supports **[TaskMaster AI](https://github.com/eyaltoledano/claude-task-master)** (aka claude-task-master) integration for advanced project management and AI-powered task planning.
CloudCLI UI supports **[TaskMaster AI](https://github.com/eyaltoledano/claude-task-master)** (aka claude-task-master) integration for advanced project management and AI-powered task planning.
It provides
- AI-powered task generation from PRDs (Product Requirements Documents)
@@ -279,7 +296,7 @@ session counts
### Backend (Node.js + Express)
- **Express Server** - RESTful API with static file serving
- **WebSocket Server** - Communication for chats and project refresh
- **Agent Integration (Claude Code / Cursor CLI / Codex)** - Process spawning and management
- **Agent Integration (Claude Code / Cursor CLI / Codex / Gemini CLI)** - Process spawning and management
- **File System API** - Exposing file browser for projects
### Frontend (React + Vite)
@@ -327,6 +344,7 @@ This project is open source and free to use, modify, and distribute under the GP
- **[Claude Code](https://docs.anthropic.com/en/docs/claude-code)** - Anthropic's official CLI
- **[Cursor CLI](https://docs.cursor.com/en/cli/overview)** - Cursor's official CLI
- **[Codex](https://developers.openai.com/codex)** - OpenAI Codex
- **[Gemini-CLI](https://geminicli.com/)** - Google Gemini CLI
- **[React](https://react.dev/)** - User interface library
- **[Vite](https://vitejs.dev/)** - Fast build tool and dev server
- **[Tailwind CSS](https://tailwindcss.com/)** - Utility-first CSS framework
@@ -336,6 +354,8 @@ This project is open source and free to use, modify, and distribute under the GP
## Support & Community
### Stay Updated
- **[Join our Discord](https://discord.gg/buxwujPNRE)** - Get help, share feedback, and connect with the community
- **[CloudCLI Cloud](https://cloudcli.ai)** - Try the hosted cloud version
- **Star** this repository to show support
- **Watch** for updates and new releases
- **Follow** the project for announcements

193
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "@siteboon/claude-code-ui",
"version": "1.21.0",
"version": "1.22.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@siteboon/claude-code-ui",
"version": "1.21.0",
"version": "1.22.0",
"hasInstallScript": true,
"license": "GPL-3.0",
"dependencies": {
@@ -43,6 +43,7 @@
"i18next": "^25.7.4",
"i18next-browser-languagedetector": "^8.2.0",
"jsonwebtoken": "^9.0.2",
"jszip": "^3.10.1",
"katex": "^0.16.25",
"lucide-react": "^0.515.0",
"mime-types": "^3.0.1",
@@ -64,6 +65,7 @@
"sqlite": "^5.1.1",
"sqlite3": "^5.1.7",
"tailwind-merge": "^3.3.1",
"web-push": "^3.6.7",
"ws": "^8.14.2"
},
"bin": {
@@ -168,7 +170,6 @@
"integrity": "sha512-UlLAnTPrFdNGoFtbSXwcGFQBtQZJCNjaN6hQNP3UPvuNXT1i82N26KL3dZeIpNalWywr9IuQuncaAfUaS1g6sQ==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@ampproject/remapping": "^2.2.0",
"@babel/code-frame": "^7.27.1",
@@ -541,7 +542,6 @@
"resolved": "https://registry.npmjs.org/@codemirror/language/-/language-6.11.2.tgz",
"integrity": "sha512-p44TsNArL4IVXDTbapUmEkAlvWs2CFQbcfc0ymDsis1kH2wh0gcY96AS29c/vp2d0y2Tquk1EDSaawpzilUiAw==",
"license": "MIT",
"peer": true,
"dependencies": {
"@codemirror/state": "^6.0.0",
"@codemirror/view": "^6.23.0",
@@ -556,7 +556,6 @@
"resolved": "https://registry.npmjs.org/@codemirror/lint/-/lint-6.8.5.tgz",
"integrity": "sha512-s3n3KisH7dx3vsoeGMxsbRAgKe4O1vbrnKBClm99PU0fWxmxsx5rR2PfqQgIt+2MMJBHbiJ5rfIdLYfB9NNvsA==",
"license": "MIT",
"peer": true,
"dependencies": {
"@codemirror/state": "^6.0.0",
"@codemirror/view": "^6.35.0",
@@ -592,7 +591,6 @@
"resolved": "https://registry.npmjs.org/@codemirror/state/-/state-6.5.2.tgz",
"integrity": "sha512-FVqsPqtPWKVVL3dPSxy8wEF/ymIEuVzF1PK3VbUgrxXpJUSHQWWZz4JMToquRxnkw+36LTamCZG2iua2Ptq0fA==",
"license": "MIT",
"peer": true,
"dependencies": {
"@marijn/find-cluster-break": "^1.0.0"
}
@@ -614,7 +612,6 @@
"resolved": "https://registry.npmjs.org/@codemirror/view/-/view-6.38.1.tgz",
"integrity": "sha512-RmTOkE7hRU3OVREqFVITWHz6ocgBjv08GoePscAakgVQfciA3SGCEk7mb9IzwW61cKKmlTpHXG6DUE5Ubx+MGQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"@codemirror/state": "^6.5.0",
"crelt": "^1.0.6",
@@ -2037,8 +2034,7 @@
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/@lezer/common/-/common-1.2.3.tgz",
"integrity": "sha512-w7ojc8ejBqr2REPsWxJjrMFsA/ysDCFICn8zEOR9mrqzOu2amhITYuLD8ag6XZf0CFXDrhKqw7+tW8cX66NaDA==",
"license": "MIT",
"peer": true
"license": "MIT"
},
"node_modules/@lezer/css": {
"version": "1.3.0",
@@ -2056,7 +2052,6 @@
"resolved": "https://registry.npmjs.org/@lezer/highlight/-/highlight-1.2.1.tgz",
"integrity": "sha512-Z5duk4RN/3zuVO7Jq0pGLJ3qynpxUVsh7IbUbGj88+uV2ApSAn6kWg2au3iJb+0Zi7kKtqffIESgNcRXWZWmSA==",
"license": "MIT",
"peer": true,
"dependencies": {
"@lezer/common": "^1.0.0"
}
@@ -2267,7 +2262,6 @@
"resolved": "https://registry.npmjs.org/@octokit/core/-/core-7.0.4.tgz",
"integrity": "sha512-jOT8V1Ba5BdC79sKrRWDdMT5l1R+XNHTPR6CPWzUP2EcfAcvIHZWF0eAbmRcpOOP5gVIwnqNg0C4nvh6Abc3OA==",
"license": "MIT",
"peer": true,
"dependencies": {
"@octokit/auth-token": "^6.0.0",
"@octokit/graphql": "^9.0.1",
@@ -3188,7 +3182,6 @@
"resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.23.tgz",
"integrity": "sha512-/LDXMQh55EzZQ0uVAZmKKhfENivEvWz6E+EYzh+/MCjMhNsotd+ZHhBGIjFDTi6+fz0OhQQQLbTgdQIxxCsC0w==",
"license": "MIT",
"peer": true,
"dependencies": {
"@types/prop-types": "*",
"csstype": "^3.0.2"
@@ -3333,8 +3326,7 @@
"version": "5.5.0",
"resolved": "https://registry.npmjs.org/@xterm/xterm/-/xterm-5.5.0.tgz",
"integrity": "sha512-hqJHYaQb5OptNunnyAnkHyM8aCjZ1MEIDTQu1iIbbTD/xops91NB5yq1ZK/dC2JDbVWtF23zUtl9JE2NqwT87A==",
"license": "MIT",
"peer": true
"license": "MIT"
},
"node_modules/abbrev": {
"version": "2.0.0",
@@ -3384,7 +3376,6 @@
"version": "7.1.4",
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz",
"integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 14"
@@ -3519,6 +3510,18 @@
"dev": true,
"license": "MIT"
},
"node_modules/asn1.js": {
"version": "5.4.1",
"resolved": "https://registry.npmjs.org/asn1.js/-/asn1.js-5.4.1.tgz",
"integrity": "sha512-+I//4cYPccV8LdmBLiX8CYvf9Sp3vQsrqu2QNXRcrbiWvcx/UdlFiqUJJzxRQxgsZmvhXhn4cSKeSmoFjVdupA==",
"license": "MIT",
"dependencies": {
"bn.js": "^4.0.0",
"inherits": "^2.0.1",
"minimalistic-assert": "^1.0.0",
"safer-buffer": "^2.1.0"
}
},
"node_modules/ast-types": {
"version": "0.13.4",
"resolved": "https://registry.npmjs.org/ast-types/-/ast-types-0.13.4.tgz",
@@ -3755,6 +3758,12 @@
"readable-stream": "^3.4.0"
}
},
"node_modules/bn.js": {
"version": "4.12.3",
"resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.3.tgz",
"integrity": "sha512-fGTi3gxV/23FTYdAoUtLYp6qySe2KE3teyZitipKNRuVYcBkoP/bB3guXN/XVKUe9mxCHXnc9C4ocyz8OmgN0g==",
"license": "MIT"
},
"node_modules/body-parser": {
"version": "1.20.3",
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.3.tgz",
@@ -3835,7 +3844,6 @@
}
],
"license": "MIT",
"peer": true,
"dependencies": {
"caniuse-lite": "^1.0.30001726",
"electron-to-chromium": "^1.5.173",
@@ -4697,6 +4705,12 @@
"integrity": "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==",
"license": "MIT"
},
"node_modules/core-util-is": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz",
"integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==",
"license": "MIT"
},
"node_modules/cors": {
"version": "2.8.5",
"resolved": "https://registry.npmjs.org/cors/-/cors-2.8.5.tgz",
@@ -6334,6 +6348,15 @@
"url": "https://github.com/sponsors/wooorm"
}
},
"node_modules/http_ece": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/http_ece/-/http_ece-1.2.0.tgz",
"integrity": "sha512-JrF8SSLVmcvc5NducxgyOrKXe3EsyHMgBFgSaIUGmArKe+rwr0uphRkRXvwiom3I+fpIfoItveHrfudL8/rxuA==",
"license": "MIT",
"engines": {
"node": ">=16"
}
},
"node_modules/http-cache-semantics": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/http-cache-semantics/-/http-cache-semantics-4.2.0.tgz",
@@ -6375,7 +6398,6 @@
"version": "7.0.6",
"resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz",
"integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==",
"dev": true,
"license": "MIT",
"dependencies": {
"agent-base": "^7.1.2",
@@ -6424,7 +6446,6 @@
}
],
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/runtime": "^7.28.4"
},
@@ -6478,6 +6499,12 @@
],
"license": "BSD-3-Clause"
},
"node_modules/immediate": {
"version": "3.0.6",
"resolved": "https://registry.npmjs.org/immediate/-/immediate-3.0.6.tgz",
"integrity": "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==",
"license": "MIT"
},
"node_modules/import-cwd": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/import-cwd/-/import-cwd-3.0.0.tgz",
@@ -6862,6 +6889,12 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/isarray": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz",
"integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==",
"license": "MIT"
},
"node_modules/isexe": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
@@ -6994,6 +7027,48 @@
"node": ">=10"
}
},
"node_modules/jszip": {
"version": "3.10.1",
"resolved": "https://registry.npmjs.org/jszip/-/jszip-3.10.1.tgz",
"integrity": "sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g==",
"license": "(MIT OR GPL-3.0-or-later)",
"dependencies": {
"lie": "~3.3.0",
"pako": "~1.0.2",
"readable-stream": "~2.3.6",
"setimmediate": "^1.0.5"
}
},
"node_modules/jszip/node_modules/readable-stream": {
"version": "2.3.8",
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz",
"integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==",
"license": "MIT",
"dependencies": {
"core-util-is": "~1.0.0",
"inherits": "~2.0.3",
"isarray": "~1.0.0",
"process-nextick-args": "~2.0.0",
"safe-buffer": "~5.1.1",
"string_decoder": "~1.1.1",
"util-deprecate": "~1.0.1"
}
},
"node_modules/jszip/node_modules/safe-buffer": {
"version": "5.1.2",
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz",
"integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==",
"license": "MIT"
},
"node_modules/jszip/node_modules/string_decoder": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz",
"integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==",
"license": "MIT",
"dependencies": {
"safe-buffer": "~5.1.0"
}
},
"node_modules/jwa": {
"version": "1.4.2",
"resolved": "https://registry.npmjs.org/jwa/-/jwa-1.4.2.tgz",
@@ -7049,6 +7124,15 @@
"node": ">=0.10.0"
}
},
"node_modules/lie": {
"version": "3.3.0",
"resolved": "https://registry.npmjs.org/lie/-/lie-3.3.0.tgz",
"integrity": "sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==",
"license": "MIT",
"dependencies": {
"immediate": "~3.0.5"
}
},
"node_modules/lilconfig": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz",
@@ -8288,6 +8372,12 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/minimalistic-assert": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/minimalistic-assert/-/minimalistic-assert-1.0.1.tgz",
"integrity": "sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A==",
"license": "ISC"
},
"node_modules/minimatch": {
"version": "9.0.5",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz",
@@ -9118,6 +9208,12 @@
"integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==",
"license": "BlueOak-1.0.0"
},
"node_modules/pako": {
"version": "1.0.11",
"resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz",
"integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==",
"license": "(MIT AND Zlib)"
},
"node_modules/parse-entities": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/parse-entities/-/parse-entities-4.0.2.tgz",
@@ -9335,7 +9431,6 @@
}
],
"license": "MIT",
"peer": true,
"dependencies": {
"nanoid": "^3.3.11",
"picocolors": "^1.1.1",
@@ -9518,6 +9613,12 @@
"node": "^14.17.0 || ^16.13.0 || >=18.0.0"
}
},
"node_modules/process-nextick-args": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz",
"integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==",
"license": "MIT"
},
"node_modules/promise-inflight": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/promise-inflight/-/promise-inflight-1.0.1.tgz",
@@ -9717,7 +9818,6 @@
"resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz",
"integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"loose-envify": "^1.1.0"
},
@@ -9730,7 +9830,6 @@
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz",
"integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==",
"license": "MIT",
"peer": true,
"dependencies": {
"loose-envify": "^1.1.0",
"scheduler": "^0.23.2"
@@ -10164,7 +10263,6 @@
}
],
"license": "MIT",
"peer": true,
"dependencies": {
"@nodeutils/defaults-deep": "1.1.0",
"@octokit/rest": "22.0.0",
@@ -10656,6 +10754,12 @@
"license": "ISC",
"optional": true
},
"node_modules/setimmediate": {
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/setimmediate/-/setimmediate-1.0.5.tgz",
"integrity": "sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==",
"license": "MIT"
},
"node_modules/setprototypeof": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz",
@@ -11900,7 +12004,6 @@
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.17.tgz",
"integrity": "sha512-w33E2aCvSDP0tW9RZuNXadXlkHXqFzSkQew/aIa2i/Sj8fThxwovwlXHSPXTbAHwEIhBFXAedUhP2tueAKP8Og==",
"license": "MIT",
"peer": true,
"dependencies": {
"@alloc/quick-lru": "^5.2.0",
"arg": "^5.0.2",
@@ -12144,7 +12247,6 @@
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"dev": true,
"license": "MIT",
"peer": true,
"engines": {
"node": ">=12"
},
@@ -12292,7 +12394,6 @@
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"devOptional": true,
"license": "Apache-2.0",
"peer": true,
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
@@ -12621,7 +12722,6 @@
"integrity": "sha512-oBXvfSHEOL8jF+R9Am7h59Up07kVVGH1NrFGFoEG6bPDZP3tGpQhvkBpy5x7U6+E6wZCu9OihsWgJqDbQIm8LQ==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"esbuild": "^0.25.0",
"fdir": "^6.5.0",
@@ -12715,7 +12815,6 @@
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"dev": true,
"license": "MIT",
"peer": true,
"engines": {
"node": ">=12"
},
@@ -12758,6 +12857,46 @@
"url": "https://github.com/sponsors/wooorm"
}
},
"node_modules/web-push": {
"version": "3.6.7",
"resolved": "https://registry.npmjs.org/web-push/-/web-push-3.6.7.tgz",
"integrity": "sha512-OpiIUe8cuGjrj3mMBFWY+e4MMIkW3SVT+7vEIjvD9kejGUypv8GPDf84JdPWskK8zMRIJ6xYGm+Kxr8YkPyA0A==",
"license": "MPL-2.0",
"dependencies": {
"asn1.js": "^5.3.0",
"http_ece": "1.2.0",
"https-proxy-agent": "^7.0.0",
"jws": "^4.0.0",
"minimist": "^1.2.5"
},
"bin": {
"web-push": "src/cli.js"
},
"engines": {
"node": ">= 16"
}
},
"node_modules/web-push/node_modules/jwa": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz",
"integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==",
"license": "MIT",
"dependencies": {
"buffer-equal-constant-time": "^1.0.1",
"ecdsa-sig-formatter": "1.0.11",
"safe-buffer": "^5.0.1"
}
},
"node_modules/web-push/node_modules/jws": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz",
"integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==",
"license": "MIT",
"dependencies": {
"jwa": "^2.0.1",
"safe-buffer": "^5.0.1"
}
},
"node_modules/webidl-conversions": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz",

View File

@@ -1,6 +1,6 @@
{
"name": "@siteboon/claude-code-ui",
"version": "1.21.0",
"version": "1.22.0",
"description": "A web-based UI for Claude Code CLI",
"type": "module",
"main": "server/index.js",
@@ -78,6 +78,7 @@
"i18next": "^25.7.4",
"i18next-browser-languagedetector": "^8.2.0",
"jsonwebtoken": "^9.0.2",
"jszip": "^3.10.1",
"katex": "^0.16.25",
"lucide-react": "^0.515.0",
"mime-types": "^3.0.1",
@@ -99,6 +100,7 @@
"sqlite": "^5.1.1",
"sqlite3": "^5.1.7",
"tailwind-merge": "^3.3.1",
"web-push": "^3.6.7",
"ws": "^8.14.2"
},
"devDependencies": {

View File

@@ -19,14 +19,17 @@ self.addEventListener('install', event => {
// Fetch event
self.addEventListener('fetch', event => {
// Never cache API requests or WebSocket upgrades
if (event.request.url.includes('/api/') || event.request.url.includes('/ws')) {
return;
}
event.respondWith(
caches.match(event.request)
.then(response => {
// Return cached response if found
if (response) {
return response;
}
// Otherwise fetch from network
return fetch(event.request);
}
)
@@ -46,4 +49,53 @@ self.addEventListener('activate', event => {
);
})
);
});
self.clients.claim();
});
// Push notification event
self.addEventListener('push', event => {
if (!event.data) return;
let payload;
try {
payload = event.data.json();
} catch {
payload = { title: 'Claude Code UI', body: event.data.text() };
}
const options = {
body: payload.body || '',
icon: '/logo-256.png',
badge: '/logo-128.png',
data: payload.data || {},
tag: payload.data?.code || 'default',
renotify: true
};
event.waitUntil(
self.registration.showNotification(payload.title || 'Claude Code UI', options)
);
});
// Notification click event
self.addEventListener('notificationclick', event => {
event.notification.close();
const sessionId = event.notification.data?.sessionId;
const urlPath = sessionId ? `/session/${sessionId}` : '/';
event.waitUntil(
self.clients.matchAll({ type: 'window', includeUncontrolled: true }).then(clientList => {
for (const client of clientList) {
if (client.url.includes(self.location.origin)) {
client.focus();
if (sessionId) {
client.navigate(self.location.origin + urlPath);
}
return;
}
}
return self.clients.openWindow(urlPath);
})
);
});

View File

@@ -18,6 +18,7 @@ import { promises as fs } from 'fs';
import path from 'path';
import os from 'os';
import { CLAUDE_MODELS } from '../shared/modelConstants.js';
import { createNotificationEvent, notifyUserIfEnabled } from './services/notification-orchestrator.js';
const activeSessions = new Map();
const pendingToolApprovals = new Map();
@@ -461,6 +462,14 @@ async function queryClaudeSDK(command, options = {}, ws) {
let tempImagePaths = [];
let tempDir = null;
const emitNotification = (event) => {
notifyUserIfEnabled({
userId: ws?.userId || null,
writer: ws,
event
});
};
try {
// Map CLI options to SDK format
const sdkOptions = mapCliOptionsToSDK(options);
@@ -477,6 +486,42 @@ async function queryClaudeSDK(command, options = {}, ws) {
tempImagePaths = imageResult.tempImagePaths;
tempDir = imageResult.tempDir;
sdkOptions.hooks = {
Notification: [{
matcher: '',
hooks: [async (input) => {
const message = typeof input?.message === 'string' ? input.message : 'Claude requires your attention.';
emitNotification(createNotificationEvent({
provider: 'claude',
sessionId: capturedSessionId || sessionId || null,
kind: 'action_required',
code: 'agent.notification',
meta: { message },
severity: 'warning',
requiresUserAction: true,
dedupeKey: `claude:hook:notification:${capturedSessionId || sessionId || 'none'}:${message}`
}));
return {};
}]
}],
Stop: [{
matcher: '',
hooks: [async (input) => {
const stopReason = typeof input?.stop_reason === 'string' ? input.stop_reason : 'completed';
emitNotification(createNotificationEvent({
provider: 'claude',
sessionId: capturedSessionId || sessionId || null,
kind: 'stop',
code: 'run.stopped',
meta: { stopReason },
severity: 'info',
dedupeKey: `claude:hook:stop:${capturedSessionId || sessionId || 'none'}:${stopReason}`
}));
return {};
}]
}]
};
sdkOptions.canUseTool = async (toolName, input, context) => {
const requiresInteraction = TOOLS_REQUIRING_INTERACTION.has(toolName);
@@ -508,6 +553,16 @@ async function queryClaudeSDK(command, options = {}, ws) {
input,
sessionId: capturedSessionId || sessionId || null
});
emitNotification(createNotificationEvent({
provider: 'claude',
sessionId: capturedSessionId || sessionId || null,
kind: 'action_required',
code: 'permission.required',
meta: { toolName },
severity: 'warning',
requiresUserAction: true,
dedupeKey: `claude:permission:${capturedSessionId || sessionId || 'none'}:${requestId}`
}));
const decision = await waitForToolApproval(requestId, {
timeoutMs: requiresInteraction ? 0 : undefined,
@@ -548,10 +603,22 @@ async function queryClaudeSDK(command, options = {}, ws) {
const prevStreamTimeout = process.env.CLAUDE_CODE_STREAM_CLOSE_TIMEOUT;
process.env.CLAUDE_CODE_STREAM_CLOSE_TIMEOUT = '300000';
const queryInstance = query({
prompt: finalCommand,
options: sdkOptions
});
let queryInstance;
try {
queryInstance = query({
prompt: finalCommand,
options: sdkOptions
});
} catch (hookError) {
// Older/newer SDK versions may not accept hook shapes yet.
// Keep notification behavior operational via runtime events even if hook registration fails.
console.warn('Failed to initialize Claude query with hooks, retrying without hooks:', hookError?.message || hookError);
delete sdkOptions.hooks;
queryInstance = query({
prompt: finalCommand,
options: sdkOptions
});
}
// Restore immediately — Query constructor already captured the value
if (prevStreamTimeout !== undefined) {
@@ -593,9 +660,6 @@ async function queryClaudeSDK(command, options = {}, ws) {
console.log('No session_id in message or already captured. message.session_id:', message.session_id, 'capturedSessionId:', capturedSessionId);
}
// logs which model was used in the message
console.log("---> Model was sent using:", Object.keys(message.modelUsage || {}));
// Transform and send message to WebSocket
const transformedMessage = transformMessage(message);
ws.send({
@@ -606,6 +670,10 @@ async function queryClaudeSDK(command, options = {}, ws) {
// Extract and send token budget updates from result messages
if (message.type === 'result') {
const models = Object.keys(message.modelUsage || {});
if (models.length > 0) {
console.log("---> Model was sent using:", models);
}
const tokenBudget = extractTokenBudget(message);
if (tokenBudget) {
console.log('Token budget from modelUsage:', tokenBudget);
@@ -653,6 +721,15 @@ async function queryClaudeSDK(command, options = {}, ws) {
error: error.message,
sessionId: capturedSessionId || sessionId || null
});
emitNotification(createNotificationEvent({
provider: 'claude',
sessionId: capturedSessionId || sessionId || null,
kind: 'error',
code: 'run.failed',
meta: { error: error.message },
severity: 'error',
dedupeKey: `claude:error:${capturedSessionId || sessionId || 'none'}:${error.message}`
}));
throw error;
}

View File

@@ -91,6 +91,36 @@ const runMigrations = () => {
db.exec('ALTER TABLE users ADD COLUMN has_completed_onboarding BOOLEAN DEFAULT 0');
}
db.exec(`
CREATE TABLE IF NOT EXISTS user_notification_preferences (
user_id INTEGER PRIMARY KEY,
preferences_json TEXT NOT NULL,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
)
`);
db.exec(`
CREATE TABLE IF NOT EXISTS vapid_keys (
id INTEGER PRIMARY KEY AUTOINCREMENT,
public_key TEXT NOT NULL,
private_key TEXT NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
)
`);
db.exec(`
CREATE TABLE IF NOT EXISTS push_subscriptions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
endpoint TEXT NOT NULL UNIQUE,
keys_p256dh TEXT NOT NULL,
keys_auth TEXT NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
)
`);
console.log('Database migrations completed successfully');
} catch (error) {
console.error('Error running migrations:', error.message);
@@ -348,6 +378,116 @@ const credentialsDb = {
}
};
const DEFAULT_NOTIFICATION_PREFERENCES = {
channels: {
inApp: false,
webPush: false
},
events: {
actionRequired: true,
stop: true,
error: true
}
};
const normalizeNotificationPreferences = (value) => {
const source = value && typeof value === 'object' ? value : {};
return {
channels: {
inApp: source.channels?.inApp === true,
webPush: source.channels?.webPush === true
},
events: {
actionRequired: source.events?.actionRequired !== false,
stop: source.events?.stop !== false,
error: source.events?.error !== false
}
};
};
const notificationPreferencesDb = {
getPreferences: (userId) => {
try {
const row = db.prepare('SELECT preferences_json FROM user_notification_preferences WHERE user_id = ?').get(userId);
if (!row) {
const defaults = normalizeNotificationPreferences(DEFAULT_NOTIFICATION_PREFERENCES);
db.prepare(
'INSERT INTO user_notification_preferences (user_id, preferences_json, updated_at) VALUES (?, ?, CURRENT_TIMESTAMP)'
).run(userId, JSON.stringify(defaults));
return defaults;
}
let parsed;
try {
parsed = JSON.parse(row.preferences_json);
} catch {
parsed = DEFAULT_NOTIFICATION_PREFERENCES;
}
return normalizeNotificationPreferences(parsed);
} catch (err) {
throw err;
}
},
updatePreferences: (userId, preferences) => {
try {
const normalized = normalizeNotificationPreferences(preferences);
db.prepare(
`INSERT INTO user_notification_preferences (user_id, preferences_json, updated_at)
VALUES (?, ?, CURRENT_TIMESTAMP)
ON CONFLICT(user_id) DO UPDATE SET
preferences_json = excluded.preferences_json,
updated_at = CURRENT_TIMESTAMP`
).run(userId, JSON.stringify(normalized));
return normalized;
} catch (err) {
throw err;
}
}
};
const pushSubscriptionsDb = {
saveSubscription: (userId, endpoint, keysP256dh, keysAuth) => {
try {
db.prepare(
`INSERT INTO push_subscriptions (user_id, endpoint, keys_p256dh, keys_auth)
VALUES (?, ?, ?, ?)
ON CONFLICT(endpoint) DO UPDATE SET
user_id = excluded.user_id,
keys_p256dh = excluded.keys_p256dh,
keys_auth = excluded.keys_auth`
).run(userId, endpoint, keysP256dh, keysAuth);
} catch (err) {
throw err;
}
},
getSubscriptions: (userId) => {
try {
return db.prepare('SELECT endpoint, keys_p256dh, keys_auth FROM push_subscriptions WHERE user_id = ?').all(userId);
} catch (err) {
throw err;
}
},
removeSubscription: (endpoint) => {
try {
db.prepare('DELETE FROM push_subscriptions WHERE endpoint = ?').run(endpoint);
} catch (err) {
throw err;
}
},
removeAllForUser: (userId) => {
try {
db.prepare('DELETE FROM push_subscriptions WHERE user_id = ?').run(userId);
} catch (err) {
throw err;
}
}
};
// Backward compatibility - keep old names pointing to new system
const githubTokensDb = {
createGithubToken: (userId, tokenName, githubToken, description = null) => {
@@ -373,5 +513,7 @@ export {
userDb,
apiKeysDb,
credentialsDb,
notificationPreferencesDb,
pushSubscriptionsDb,
githubTokensDb // Backward compatibility
};
};

View File

@@ -49,4 +49,31 @@ CREATE TABLE IF NOT EXISTS user_credentials (
CREATE INDEX IF NOT EXISTS idx_user_credentials_user_id ON user_credentials(user_id);
CREATE INDEX IF NOT EXISTS idx_user_credentials_type ON user_credentials(credential_type);
CREATE INDEX IF NOT EXISTS idx_user_credentials_active ON user_credentials(is_active);
CREATE INDEX IF NOT EXISTS idx_user_credentials_active ON user_credentials(is_active);
-- User notification preferences (backend-owned, provider-agnostic)
CREATE TABLE IF NOT EXISTS user_notification_preferences (
user_id INTEGER PRIMARY KEY,
preferences_json TEXT NOT NULL,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
);
-- VAPID key pair for Web Push notifications
CREATE TABLE IF NOT EXISTS vapid_keys (
id INTEGER PRIMARY KEY AUTOINCREMENT,
public_key TEXT NOT NULL,
private_key TEXT NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
-- Browser push subscriptions
CREATE TABLE IF NOT EXISTS push_subscriptions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
endpoint TEXT NOT NULL UNIQUE,
keys_p256dh TEXT NOT NULL,
keys_auth TEXT NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
);

View File

@@ -65,6 +65,7 @@ import userRoutes from './routes/user.js';
import codexRoutes from './routes/codex.js';
import geminiRoutes from './routes/gemini.js';
import { initializeDatabase } from './database/db.js';
import { configureWebPush } from './services/vapid-keys.js';
import { validateApiKey, authenticateToken, authenticateWebSocket } from './middleware/auth.js';
import { IS_PLATFORM } from './constants/config.js';
@@ -884,6 +885,436 @@ app.get('/api/projects/:projectName/files', authenticateToken, async (req, res)
}
});
// ============================================================================
// FILE OPERATIONS API ENDPOINTS
// ============================================================================
/**
* Validate that a path is within the project root
* @param {string} projectRoot - The project root path
* @param {string} targetPath - The path to validate
* @returns {{ valid: boolean, resolved?: string, error?: string }}
*/
function validatePathInProject(projectRoot, targetPath) {
const resolved = path.isAbsolute(targetPath)
? path.resolve(targetPath)
: path.resolve(projectRoot, targetPath);
const normalizedRoot = path.resolve(projectRoot) + path.sep;
if (!resolved.startsWith(normalizedRoot)) {
return { valid: false, error: 'Path must be under project root' };
}
return { valid: true, resolved };
}
/**
* Validate filename - check for invalid characters
* @param {string} name - The filename to validate
* @returns {{ valid: boolean, error?: string }}
*/
function validateFilename(name) {
if (!name || !name.trim()) {
return { valid: false, error: 'Filename cannot be empty' };
}
// Check for invalid characters (Windows + Unix)
const invalidChars = /[<>:"/\\|?*\x00-\x1f]/;
if (invalidChars.test(name)) {
return { valid: false, error: 'Filename contains invalid characters' };
}
// Check for reserved names (Windows)
const reserved = /^(CON|PRN|AUX|NUL|COM[1-9]|LPT[1-9])$/i;
if (reserved.test(name)) {
return { valid: false, error: 'Filename is a reserved name' };
}
// Check for dots only
if (/^\.+$/.test(name)) {
return { valid: false, error: 'Filename cannot be only dots' };
}
return { valid: true };
}
// POST /api/projects/:projectName/files/create - Create new file or directory
app.post('/api/projects/:projectName/files/create', authenticateToken, async (req, res) => {
try {
const { projectName } = req.params;
const { path: parentPath, type, name } = req.body;
// Validate input
if (!name || !type) {
return res.status(400).json({ error: 'Name and type are required' });
}
if (!['file', 'directory'].includes(type)) {
return res.status(400).json({ error: 'Type must be "file" or "directory"' });
}
const nameValidation = validateFilename(name);
if (!nameValidation.valid) {
return res.status(400).json({ error: nameValidation.error });
}
// Get project root
const projectRoot = await extractProjectDirectory(projectName).catch(() => null);
if (!projectRoot) {
return res.status(404).json({ error: 'Project not found' });
}
// Build and validate target path
const targetDir = parentPath || '';
const targetPath = targetDir ? path.join(targetDir, name) : name;
const validation = validatePathInProject(projectRoot, targetPath);
if (!validation.valid) {
return res.status(403).json({ error: validation.error });
}
const resolvedPath = validation.resolved;
// Check if already exists
try {
await fsPromises.access(resolvedPath);
return res.status(409).json({ error: `${type === 'file' ? 'File' : 'Directory'} already exists` });
} catch {
// Doesn't exist, which is what we want
}
// Create file or directory
if (type === 'directory') {
await fsPromises.mkdir(resolvedPath, { recursive: false });
} else {
// Ensure parent directory exists
const parentDir = path.dirname(resolvedPath);
try {
await fsPromises.access(parentDir);
} catch {
await fsPromises.mkdir(parentDir, { recursive: true });
}
await fsPromises.writeFile(resolvedPath, '', 'utf8');
}
res.json({
success: true,
path: resolvedPath,
name,
type,
message: `${type === 'file' ? 'File' : 'Directory'} created successfully`
});
} catch (error) {
console.error('Error creating file/directory:', error);
if (error.code === 'EACCES') {
res.status(403).json({ error: 'Permission denied' });
} else if (error.code === 'ENOENT') {
res.status(404).json({ error: 'Parent directory not found' });
} else {
res.status(500).json({ error: error.message });
}
}
});
// PUT /api/projects/:projectName/files/rename - Rename file or directory
app.put('/api/projects/:projectName/files/rename', authenticateToken, async (req, res) => {
try {
const { projectName } = req.params;
const { oldPath, newName } = req.body;
// Validate input
if (!oldPath || !newName) {
return res.status(400).json({ error: 'oldPath and newName are required' });
}
const nameValidation = validateFilename(newName);
if (!nameValidation.valid) {
return res.status(400).json({ error: nameValidation.error });
}
// Get project root
const projectRoot = await extractProjectDirectory(projectName).catch(() => null);
if (!projectRoot) {
return res.status(404).json({ error: 'Project not found' });
}
// Validate old path
const oldValidation = validatePathInProject(projectRoot, oldPath);
if (!oldValidation.valid) {
return res.status(403).json({ error: oldValidation.error });
}
const resolvedOldPath = oldValidation.resolved;
// Check if old path exists
try {
await fsPromises.access(resolvedOldPath);
} catch {
return res.status(404).json({ error: 'File or directory not found' });
}
// Build and validate new path
const parentDir = path.dirname(resolvedOldPath);
const resolvedNewPath = path.join(parentDir, newName);
const newValidation = validatePathInProject(projectRoot, resolvedNewPath);
if (!newValidation.valid) {
return res.status(403).json({ error: newValidation.error });
}
// Check if new path already exists
try {
await fsPromises.access(resolvedNewPath);
return res.status(409).json({ error: 'A file or directory with this name already exists' });
} catch {
// Doesn't exist, which is what we want
}
// Rename
await fsPromises.rename(resolvedOldPath, resolvedNewPath);
res.json({
success: true,
oldPath: resolvedOldPath,
newPath: resolvedNewPath,
newName,
message: 'Renamed successfully'
});
} catch (error) {
console.error('Error renaming file/directory:', error);
if (error.code === 'EACCES') {
res.status(403).json({ error: 'Permission denied' });
} else if (error.code === 'ENOENT') {
res.status(404).json({ error: 'File or directory not found' });
} else if (error.code === 'EXDEV') {
res.status(400).json({ error: 'Cannot move across different filesystems' });
} else {
res.status(500).json({ error: error.message });
}
}
});
// DELETE /api/projects/:projectName/files - Delete file or directory
app.delete('/api/projects/:projectName/files', authenticateToken, async (req, res) => {
try {
const { projectName } = req.params;
const { path: targetPath, type } = req.body;
// Validate input
if (!targetPath) {
return res.status(400).json({ error: 'Path is required' });
}
// Get project root
const projectRoot = await extractProjectDirectory(projectName).catch(() => null);
if (!projectRoot) {
return res.status(404).json({ error: 'Project not found' });
}
// Validate path
const validation = validatePathInProject(projectRoot, targetPath);
if (!validation.valid) {
return res.status(403).json({ error: validation.error });
}
const resolvedPath = validation.resolved;
// Check if path exists and get stats
let stats;
try {
stats = await fsPromises.stat(resolvedPath);
} catch {
return res.status(404).json({ error: 'File or directory not found' });
}
// Prevent deleting the project root itself
if (resolvedPath === path.resolve(projectRoot)) {
return res.status(403).json({ error: 'Cannot delete project root directory' });
}
// Delete based on type
if (stats.isDirectory()) {
await fsPromises.rm(resolvedPath, { recursive: true, force: true });
} else {
await fsPromises.unlink(resolvedPath);
}
res.json({
success: true,
path: resolvedPath,
type: stats.isDirectory() ? 'directory' : 'file',
message: 'Deleted successfully'
});
} catch (error) {
console.error('Error deleting file/directory:', error);
if (error.code === 'EACCES') {
res.status(403).json({ error: 'Permission denied' });
} else if (error.code === 'ENOENT') {
res.status(404).json({ error: 'File or directory not found' });
} else if (error.code === 'ENOTEMPTY') {
res.status(400).json({ error: 'Directory is not empty' });
} else {
res.status(500).json({ error: error.message });
}
}
});
// POST /api/projects/:projectName/files/upload - Upload files
// Dynamic import of multer for file uploads
const uploadFilesHandler = async (req, res) => {
// Dynamic import of multer
const multer = (await import('multer')).default;
const uploadMiddleware = multer({
storage: multer.diskStorage({
destination: (req, file, cb) => {
cb(null, os.tmpdir());
},
filename: (req, file, cb) => {
// Use a unique temp name, but preserve original name in file.originalname
// Note: file.originalname may contain path separators for folder uploads
const uniqueSuffix = Date.now() + '-' + Math.round(Math.random() * 1E9);
// For temp file, just use a safe unique name without the path
cb(null, `upload-${uniqueSuffix}`);
}
}),
limits: {
fileSize: 50 * 1024 * 1024, // 50MB limit
files: 20 // Max 20 files at once
}
});
// Use multer middleware
uploadMiddleware.array('files', 20)(req, res, async (err) => {
if (err) {
console.error('Multer error:', err);
if (err.code === 'LIMIT_FILE_SIZE') {
return res.status(400).json({ error: 'File too large. Maximum size is 50MB.' });
}
if (err.code === 'LIMIT_FILE_COUNT') {
return res.status(400).json({ error: 'Too many files. Maximum is 20 files.' });
}
return res.status(500).json({ error: err.message });
}
try {
const { projectName } = req.params;
const { targetPath, relativePaths } = req.body;
// Parse relative paths if provided (for folder uploads)
let filePaths = [];
if (relativePaths) {
try {
filePaths = JSON.parse(relativePaths);
} catch (e) {
console.log('[DEBUG] Failed to parse relativePaths:', relativePaths);
}
}
console.log('[DEBUG] File upload request:', {
projectName,
targetPath: JSON.stringify(targetPath),
targetPathType: typeof targetPath,
filesCount: req.files?.length,
relativePaths: filePaths
});
if (!req.files || req.files.length === 0) {
return res.status(400).json({ error: 'No files provided' });
}
// Get project root
const projectRoot = await extractProjectDirectory(projectName).catch(() => null);
if (!projectRoot) {
return res.status(404).json({ error: 'Project not found' });
}
console.log('[DEBUG] Project root:', projectRoot);
// Validate and resolve target path
// If targetPath is empty or '.', use project root directly
const targetDir = targetPath || '';
let resolvedTargetDir;
console.log('[DEBUG] Target dir:', JSON.stringify(targetDir));
if (!targetDir || targetDir === '.' || targetDir === './') {
// Empty path means upload to project root
resolvedTargetDir = path.resolve(projectRoot);
console.log('[DEBUG] Using project root as target:', resolvedTargetDir);
} else {
const validation = validatePathInProject(projectRoot, targetDir);
if (!validation.valid) {
console.log('[DEBUG] Path validation failed:', validation.error);
return res.status(403).json({ error: validation.error });
}
resolvedTargetDir = validation.resolved;
console.log('[DEBUG] Resolved target dir:', resolvedTargetDir);
}
// Ensure target directory exists
try {
await fsPromises.access(resolvedTargetDir);
} catch {
await fsPromises.mkdir(resolvedTargetDir, { recursive: true });
}
// Move uploaded files from temp to target directory
const uploadedFiles = [];
console.log('[DEBUG] Processing files:', req.files.map(f => ({ originalname: f.originalname, path: f.path })));
for (let i = 0; i < req.files.length; i++) {
const file = req.files[i];
// Use relative path if provided (for folder uploads), otherwise use originalname
const fileName = (filePaths && filePaths[i]) ? filePaths[i] : file.originalname;
console.log('[DEBUG] Processing file:', fileName, '(originalname:', file.originalname + ')');
const destPath = path.join(resolvedTargetDir, fileName);
// Validate destination path
const destValidation = validatePathInProject(projectRoot, destPath);
if (!destValidation.valid) {
console.log('[DEBUG] Destination validation failed for:', destPath);
// Clean up temp file
await fsPromises.unlink(file.path).catch(() => {});
continue;
}
// Ensure parent directory exists (for nested files from folder upload)
const parentDir = path.dirname(destPath);
try {
await fsPromises.access(parentDir);
} catch {
await fsPromises.mkdir(parentDir, { recursive: true });
}
// Move file (copy + unlink to handle cross-device scenarios)
await fsPromises.copyFile(file.path, destPath);
await fsPromises.unlink(file.path);
uploadedFiles.push({
name: fileName,
path: destPath,
size: file.size,
mimeType: file.mimetype
});
}
res.json({
success: true,
files: uploadedFiles,
targetPath: resolvedTargetDir,
message: `Uploaded ${uploadedFiles.length} file(s) successfully`
});
} catch (error) {
console.error('Error uploading files:', error);
// Clean up any remaining temp files
if (req.files) {
for (const file of req.files) {
await fsPromises.unlink(file.path).catch(() => {});
}
}
if (error.code === 'EACCES') {
res.status(403).json({ error: 'Permission denied' });
} else {
res.status(500).json({ error: error.message });
}
}
});
};
app.post('/api/projects/:projectName/files/upload', authenticateToken, uploadFilesHandler);
// WebSocket connection handler that routes based on URL path
wss.on('connection', (ws, request) => {
const url = request.url;
@@ -896,7 +1327,7 @@ wss.on('connection', (ws, request) => {
if (pathname === '/shell') {
handleShellConnection(ws);
} else if (pathname === '/ws') {
handleChatConnection(ws);
handleChatConnection(ws, request);
} else {
console.log('[WARN] Unknown WebSocket path:', pathname);
ws.close();
@@ -907,9 +1338,10 @@ wss.on('connection', (ws, request) => {
* WebSocket Writer - Wrapper for WebSocket to match SSEStreamWriter interface
*/
class WebSocketWriter {
constructor(ws) {
constructor(ws, userId = null) {
this.ws = ws;
this.sessionId = null;
this.userId = userId;
this.isWebSocketWriter = true; // Marker for transport detection
}
@@ -930,14 +1362,14 @@ class WebSocketWriter {
}
// Handle chat WebSocket connections
function handleChatConnection(ws) {
function handleChatConnection(ws, request) {
console.log('[INFO] Chat WebSocket connected');
// Add to connected clients for project updates
connectedClients.add(ws);
// Wrap WebSocket with writer for consistent interface with SSEStreamWriter
const writer = new WebSocketWriter(ws);
const writer = new WebSocketWriter(ws, request?.user?.id ?? request?.user?.userId ?? null);
ws.on('message', async (message) => {
try {
@@ -1218,7 +1650,7 @@ function handleShellConnection(ws) {
if (hasSession && sessionId) {
try {
// Gemini CLI enforces its own native session IDs, unlike other agents that accept arbitrary string names.
// The UI only knows about its internal generated `sessionId` (e.g. gemini_1234).
// The UI only knows about its internal generated `sessionId` (e.g. gemini_1234).
// We must fetch the mapping from the backend session manager to pass the native `cliSessionId` to the shell.
const sess = sessionManager.getSession(sessionId);
if (sess && sess.cliSessionId) {
@@ -1791,8 +2223,8 @@ app.get('/api/projects/:projectName/sessions/:sessionId/token-usage', authentica
// Construct the JSONL file path
// Claude stores session files in ~/.claude/projects/[encoded-project-path]/[session-id].jsonl
// The encoding replaces /, spaces, ~, and _ with -
const encodedPath = projectPath.replace(/[\\/:\s~_]/g, '-');
// The encoding replaces any non-alphanumeric character (except -) with -
const encodedPath = projectPath.replace(/[^a-zA-Z0-9-]/g, '-');
const projectDir = path.join(homeDir, '.claude', 'projects', encodedPath);
const jsonlPath = path.join(projectDir, `${safeSessionId}.jsonl`);
@@ -1980,6 +2412,9 @@ async function startServer() {
// Initialize authentication database
await initializeDatabase();
// Configure Web Push (VAPID keys)
configureWebPush();
// Check if running in production mode (dist folder exists)
const distIndexPath = path.join(__dirname, '../dist/index.html');
const isProduction = fs.existsSync(distIndexPath);

View File

@@ -85,7 +85,7 @@ const authenticateWebSocket = (token) => {
try {
const user = userDb.getFirstUser();
if (user) {
return { userId: user.id, username: user.username };
return { id: user.id, userId: user.id, username: user.username };
}
return null;
} catch (error) {
@@ -101,7 +101,10 @@ const authenticateWebSocket = (token) => {
try {
const decoded = jwt.verify(token, JWT_SECRET);
return decoded;
return {
...decoded,
id: decoded.userId
};
} catch (error) {
console.error('WebSocket token verification error:', error);
return null;
@@ -114,4 +117,4 @@ export {
generateToken,
authenticateWebSocket,
JWT_SECRET
};
};

View File

@@ -1,5 +1,7 @@
import express from 'express';
import { apiKeysDb, credentialsDb } from '../database/db.js';
import { apiKeysDb, credentialsDb, notificationPreferencesDb, pushSubscriptionsDb } from '../database/db.js';
import { getPublicKey } from '../services/vapid-keys.js';
import { createNotificationEvent, notifyUserIfEnabled } from '../services/notification-orchestrator.js';
const router = express.Router();
@@ -175,4 +177,100 @@ router.patch('/credentials/:credentialId/toggle', async (req, res) => {
}
});
// ===============================
// Notification Preferences
// ===============================
router.get('/notification-preferences', async (req, res) => {
try {
const preferences = notificationPreferencesDb.getPreferences(req.user.id);
res.json({ success: true, preferences });
} catch (error) {
console.error('Error fetching notification preferences:', error);
res.status(500).json({ error: 'Failed to fetch notification preferences' });
}
});
router.put('/notification-preferences', async (req, res) => {
try {
const preferences = notificationPreferencesDb.updatePreferences(req.user.id, req.body || {});
res.json({ success: true, preferences });
} catch (error) {
console.error('Error saving notification preferences:', error);
res.status(500).json({ error: 'Failed to save notification preferences' });
}
});
// ===============================
// Push Subscription Management
// ===============================
router.get('/push/vapid-public-key', async (req, res) => {
try {
const publicKey = getPublicKey();
res.json({ publicKey });
} catch (error) {
console.error('Error fetching VAPID public key:', error);
res.status(500).json({ error: 'Failed to fetch VAPID public key' });
}
});
router.post('/push/subscribe', async (req, res) => {
try {
const { endpoint, keys } = req.body;
if (!endpoint || !keys?.p256dh || !keys?.auth) {
return res.status(400).json({ error: 'Missing subscription fields' });
}
pushSubscriptionsDb.saveSubscription(req.user.id, endpoint, keys.p256dh, keys.auth);
// Enable webPush in preferences so the confirmation goes through the full pipeline
const currentPrefs = notificationPreferencesDb.getPreferences(req.user.id);
if (!currentPrefs?.channels?.webPush) {
notificationPreferencesDb.updatePreferences(req.user.id, {
...currentPrefs,
channels: { ...currentPrefs?.channels, webPush: true },
});
}
res.json({ success: true });
// Send a confirmation push through the full notification pipeline
const event = createNotificationEvent({
provider: 'system',
kind: 'info',
code: 'push.enabled',
meta: { message: 'Push notifications are now enabled!' },
severity: 'info'
});
notifyUserIfEnabled({ userId: req.user.id, event });
} catch (error) {
console.error('Error saving push subscription:', error);
res.status(500).json({ error: 'Failed to save push subscription' });
}
});
router.post('/push/unsubscribe', async (req, res) => {
try {
const { endpoint } = req.body;
if (!endpoint) {
return res.status(400).json({ error: 'Missing endpoint' });
}
pushSubscriptionsDb.removeSubscription(endpoint);
// Disable webPush in preferences to match subscription state
const currentPrefs = notificationPreferencesDb.getPreferences(req.user.id);
if (currentPrefs?.channels?.webPush) {
notificationPreferencesDb.updatePreferences(req.user.id, {
...currentPrefs,
channels: { ...currentPrefs.channels, webPush: false },
});
}
res.json({ success: true });
} catch (error) {
console.error('Error removing push subscription:', error);
res.status(500).json({ error: 'Failed to remove push subscription' });
}
});
export default router;

View File

@@ -0,0 +1,137 @@
import webPush from 'web-push';
import { notificationPreferencesDb, pushSubscriptionsDb } from '../database/db.js';
const KIND_TO_PREF_KEY = {
action_required: 'actionRequired',
stop: 'stop',
error: 'error'
};
const recentEventKeys = new Map();
const DEDUPE_WINDOW_MS = 20000;
const cleanupOldEventKeys = () => {
const now = Date.now();
for (const [key, timestamp] of recentEventKeys.entries()) {
if (now - timestamp > DEDUPE_WINDOW_MS) {
recentEventKeys.delete(key);
}
}
};
function shouldSendPush(preferences, event) {
const webPushEnabled = Boolean(preferences?.channels?.webPush);
const prefEventKey = KIND_TO_PREF_KEY[event.kind];
const eventEnabled = prefEventKey ? Boolean(preferences?.events?.[prefEventKey]) : true;
return webPushEnabled && eventEnabled;
}
function isDuplicate(event) {
cleanupOldEventKeys();
const key = event.dedupeKey || `${event.provider}:${event.kind || 'info'}:${event.code || 'generic'}:${event.sessionId || 'none'}`;
if (recentEventKeys.has(key)) {
return true;
}
recentEventKeys.set(key, Date.now());
return false;
}
function createNotificationEvent({
provider,
sessionId = null,
kind = 'info',
code = 'generic.info',
meta = {},
severity = 'info',
dedupeKey = null,
requiresUserAction = false
}) {
return {
provider,
sessionId,
kind,
code,
meta,
severity,
requiresUserAction,
dedupeKey,
createdAt: new Date().toISOString()
};
}
function buildPushBody(event) {
const CODE_MAP = {
'permission.required': event.meta?.toolName
? `Action Required: Tool "${event.meta.toolName}" needs approval`
: 'Action Required: A tool needs your approval',
'run.stopped': event.meta?.stopReason || 'Run Stopped: The run has stopped',
'run.failed': event.meta?.error ? `Run Failed: ${event.meta.error}` : 'Run Failed: The run encountered an error',
'agent.notification': event.meta?.message ? String(event.meta.message) : 'You have a new notification',
'push.enabled': 'Push notifications are now enabled!'
};
return {
title: 'Claude Code UI',
body: CODE_MAP[event.code] || 'You have a new notification',
data: {
sessionId: event.sessionId || null,
code: event.code
}
};
}
async function sendWebPush(userId, event) {
const subscriptions = pushSubscriptionsDb.getSubscriptions(userId);
if (!subscriptions.length) return;
const payload = JSON.stringify(buildPushBody(event));
const results = await Promise.allSettled(
subscriptions.map((sub) =>
webPush.sendNotification(
{
endpoint: sub.endpoint,
keys: {
p256dh: sub.keys_p256dh,
auth: sub.keys_auth
}
},
payload
)
)
);
// Clean up gone subscriptions (410 Gone or 404)
results.forEach((result, index) => {
if (result.status === 'rejected') {
const statusCode = result.reason?.statusCode;
if (statusCode === 410 || statusCode === 404) {
pushSubscriptionsDb.removeSubscription(subscriptions[index].endpoint);
}
}
});
}
function notifyUserIfEnabled({ userId, event }) {
if (!userId || !event) {
return;
}
const preferences = notificationPreferencesDb.getPreferences(userId);
if (!shouldSendPush(preferences, event)) {
return;
}
if (isDuplicate(event)) {
return;
}
sendWebPush(userId, event).catch((err) => {
console.error('Web push send error:', err);
});
}
export {
createNotificationEvent,
notifyUserIfEnabled
};

View File

@@ -0,0 +1,35 @@
import webPush from 'web-push';
import { db } from '../database/db.js';
let cachedKeys = null;
function ensureVapidKeys() {
if (cachedKeys) return cachedKeys;
const row = db.prepare('SELECT public_key, private_key FROM vapid_keys ORDER BY id DESC LIMIT 1').get();
if (row) {
cachedKeys = { publicKey: row.public_key, privateKey: row.private_key };
return cachedKeys;
}
const keys = webPush.generateVAPIDKeys();
db.prepare('INSERT INTO vapid_keys (public_key, private_key) VALUES (?, ?)').run(keys.publicKey, keys.privateKey);
cachedKeys = keys;
return cachedKeys;
}
function getPublicKey() {
return ensureVapidKeys().publicKey;
}
function configureWebPush() {
const keys = ensureVapidKeys();
webPush.setVapidDetails(
'mailto:noreply@claudecodeui.local',
keys.publicKey,
keys.privateKey
);
console.log('Web Push notifications configured');
}
export { ensureVapidKeys, getPublicKey, configureWebPush };

View 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;

View File

@@ -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,
};

View File

@@ -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;

View 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 ?? '');
};

View File

@@ -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' : ''}`;

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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" />

View File

@@ -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,
};
}

View File

@@ -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,
};
}

View 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,
};
}

View 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,
};
};

View File

@@ -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>
);
}

View File

@@ -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>
</>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>

View File

@@ -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

View File

@@ -18,6 +18,7 @@ export const SETTINGS_MAIN_TABS: SettingsMainTab[] = [
'git',
'api',
'tasks',
'notifications',
];
export const AGENT_PROVIDERS: AgentProvider[] = ['claude', 'cursor', 'codex'];

View File

@@ -20,6 +20,7 @@ import type {
McpServer,
McpToolsResult,
McpTestResult,
NotificationPreferencesState,
ProjectSortOrder,
SettingsMainTab,
SettingsProject,
@@ -95,9 +96,14 @@ type CodexSettingsStorage = {
permissionMode?: CodexPermissionMode;
};
type NotificationPreferencesResponse = {
success?: boolean;
preferences?: NotificationPreferencesState;
};
type ActiveLoginProvider = AgentProvider | '';
const KNOWN_MAIN_TABS: SettingsMainTab[] = ['agents', 'appearance', 'git', 'api', 'tasks'];
const KNOWN_MAIN_TABS: SettingsMainTab[] = ['agents', 'appearance', 'git', 'api', 'tasks', 'notifications'];
const normalizeMainTab = (tab: string): SettingsMainTab => {
// Keep backwards compatibility with older callers that still pass "tools".
@@ -185,6 +191,18 @@ const createEmptyCursorPermissions = (): CursorPermissionsState => ({
...DEFAULT_CURSOR_PERMISSIONS,
});
const createDefaultNotificationPreferences = (): NotificationPreferencesState => ({
channels: {
inApp: true,
webPush: false,
},
events: {
actionRequired: true,
stop: true,
error: true,
},
});
export function useSettingsController({ isOpen, initialTab, projects, onClose }: UseSettingsControllerArgs) {
const { isDarkMode, toggleDarkMode } = useTheme() as ThemeContextValue;
const closeTimerRef = useRef<number | null>(null);
@@ -204,6 +222,9 @@ export function useSettingsController({ isOpen, initialTab, projects, onClose }:
const [cursorPermissions, setCursorPermissions] = useState<CursorPermissionsState>(() => (
createEmptyCursorPermissions()
));
const [notificationPreferences, setNotificationPreferences] = useState<NotificationPreferencesState>(() => (
createDefaultNotificationPreferences()
));
const [codexPermissionMode, setCodexPermissionMode] = useState<CodexPermissionMode>('default');
const [geminiPermissionMode, setGeminiPermissionMode] = useState<GeminiPermissionMode>('default');
@@ -669,6 +690,22 @@ export function useSettingsController({ isOpen, initialTab, projects, onClose }:
);
setGeminiPermissionMode(savedGeminiSettings.permissionMode || 'default');
try {
const notificationResponse = await authenticatedFetch('/api/settings/notification-preferences');
if (notificationResponse.ok) {
const notificationData = await toResponseJson<NotificationPreferencesResponse>(notificationResponse);
if (notificationData.success && notificationData.preferences) {
setNotificationPreferences(notificationData.preferences);
} else {
setNotificationPreferences(createDefaultNotificationPreferences());
}
} else {
setNotificationPreferences(createDefaultNotificationPreferences());
}
} catch {
setNotificationPreferences(createDefaultNotificationPreferences());
}
await Promise.all([
fetchMcpServers(),
fetchCursorMcpServers(),
@@ -678,6 +715,7 @@ export function useSettingsController({ isOpen, initialTab, projects, onClose }:
console.error('Error loading settings:', error);
setClaudePermissions(createEmptyClaudePermissions());
setCursorPermissions(createEmptyCursorPermissions());
setNotificationPreferences(createDefaultNotificationPreferences());
setCodexPermissionMode('default');
setProjectSortOrder('name');
}
@@ -698,7 +736,7 @@ export function useSettingsController({ isOpen, initialTab, projects, onClose }:
void checkAuthStatus(loginProvider);
}, [checkAuthStatus, loginProvider]);
const saveSettings = useCallback(() => {
const saveSettings = useCallback(async () => {
setIsSaving(true);
setSaveStatus(null);
@@ -729,6 +767,14 @@ export function useSettingsController({ isOpen, initialTab, projects, onClose }:
lastUpdated: now,
}));
const notificationResponse = await authenticatedFetch('/api/settings/notification-preferences', {
method: 'PUT',
body: JSON.stringify(notificationPreferences),
});
if (!notificationResponse.ok) {
throw new Error('Failed to save notification preferences');
}
setSaveStatus('success');
if (closeTimerRef.current !== null) {
window.clearTimeout(closeTimerRef.current);
@@ -749,6 +795,7 @@ export function useSettingsController({ isOpen, initialTab, projects, onClose }:
cursorPermissions.allowedCommands,
cursorPermissions.disallowedCommands,
cursorPermissions.skipPermissions,
notificationPreferences,
onClose,
projectSortOrder,
]);
@@ -825,6 +872,8 @@ export function useSettingsController({ isOpen, initialTab, projects, onClose }:
setClaudePermissions,
cursorPermissions,
setCursorPermissions,
notificationPreferences,
setNotificationPreferences,
codexPermissionMode,
setCodexPermissionMode,
mcpServers,

View File

@@ -1,6 +1,6 @@
import type { Dispatch, SetStateAction } from 'react';
export type SettingsMainTab = 'agents' | 'appearance' | 'git' | 'api' | 'tasks';
export type SettingsMainTab = 'agents' | 'appearance' | 'git' | 'api' | 'tasks' | 'notifications';
export type AgentProvider = 'claude' | 'cursor' | 'codex' | 'gemini';
export type AgentCategory = 'account' | 'permissions' | 'mcp';
export type ProjectSortOrder = 'name' | 'date';
@@ -105,6 +105,18 @@ export type ClaudePermissionsState = {
skipPermissions: boolean;
};
export type NotificationPreferencesState = {
channels: {
inApp: boolean;
webPush: boolean;
};
events: {
actionRequired: boolean;
stop: boolean;
error: boolean;
};
};
export type CursorPermissionsState = {
allowedCommands: string[];
disallowedCommands: string[];

View File

@@ -9,8 +9,10 @@ import AgentsSettingsTab from '../view/tabs/agents-settings/AgentsSettingsTab';
import AppearanceSettingsTab from '../view/tabs/AppearanceSettingsTab';
import CredentialsSettingsTab from '../view/tabs/api-settings/CredentialsSettingsTab';
import GitSettingsTab from '../view/tabs/git-settings/GitSettingsTab';
import NotificationsSettingsTab from '../view/tabs/NotificationsSettingsTab';
import TasksSettingsTab from '../view/tabs/tasks-settings/TasksSettingsTab';
import { useSettingsController } from '../hooks/useSettingsController';
import { useWebPush } from '../../../hooks/useWebPush';
import type { AgentProvider, SettingsProject, SettingsProps } from '../types/types';
type LoginModalProps = {
@@ -38,6 +40,8 @@ function Settings({ isOpen, onClose, projects = [], initialTab = 'agents' }: Set
updateCodeEditorSetting,
claudePermissions,
setClaudePermissions,
notificationPreferences,
setNotificationPreferences,
cursorPermissions,
setCursorPermissions,
codexPermissionMode,
@@ -82,6 +86,32 @@ function Settings({ isOpen, onClose, projects = [], initialTab = 'agents' }: Set
onClose,
});
const {
permission: pushPermission,
isSubscribed: isPushSubscribed,
isLoading: isPushLoading,
subscribe: pushSubscribe,
unsubscribe: pushUnsubscribe,
} = useWebPush();
const handleEnablePush = async () => {
await pushSubscribe();
// Server sets webPush: true in preferences on subscribe; sync local state
setNotificationPreferences({
...notificationPreferences,
channels: { ...notificationPreferences.channels, webPush: true },
});
};
const handleDisablePush = async () => {
await pushUnsubscribe();
// Server sets webPush: false in preferences on unsubscribe; sync local state
setNotificationPreferences({
...notificationPreferences,
channels: { ...notificationPreferences.channels, webPush: false },
});
};
if (!isOpen) {
return null;
}
@@ -171,6 +201,18 @@ function Settings({ isOpen, onClose, projects = [], initialTab = 'agents' }: Set
</div>
)}
{activeTab === 'notifications' && (
<NotificationsSettingsTab
notificationPreferences={notificationPreferences}
onNotificationPreferencesChange={setNotificationPreferences}
pushPermission={pushPermission}
isPushSubscribed={isPushSubscribed}
isPushLoading={isPushLoading}
onEnablePush={handleEnablePush}
onDisablePush={handleDisablePush}
/>
)}
{activeTab === 'api' && (
<div className="space-y-6 md:space-y-8">
<CredentialsSettingsTab />

View File

@@ -19,6 +19,7 @@ const TAB_CONFIG: MainTabConfig[] = [
{ id: 'git', labelKey: 'mainTabs.git', icon: GitBranch },
{ id: 'api', labelKey: 'mainTabs.apiTokens', icon: Key },
{ id: 'tasks', labelKey: 'mainTabs.tasks' },
{ id: 'notifications', labelKey: 'mainTabs.notifications' },
];
export default function SettingsMainTabs({ activeTab, onChange }: SettingsMainTabsProps) {
@@ -26,7 +27,7 @@ export default function SettingsMainTabs({ activeTab, onChange }: SettingsMainTa
return (
<div className="border-b border-border">
<div className="flex px-4 md:px-6" role="tablist" aria-label={t('mainTabs.label', { defaultValue: 'Settings' })}>
<div className="flex px-4 md:px-6 overflow-x-auto scrollbar-hide" role="tablist" aria-label={t('mainTabs.label', { defaultValue: 'Settings' })}>
{TAB_CONFIG.map((tab) => {
const Icon = tab.icon;
const isActive = activeTab === tab.id;
@@ -37,7 +38,7 @@ export default function SettingsMainTabs({ activeTab, onChange }: SettingsMainTa
role="tab"
aria-selected={isActive}
onClick={() => onChange(tab.id)}
className={`px-4 py-3 text-sm font-medium border-b-2 transition-colors ${
className={`px-4 py-3 text-sm font-medium border-b-2 transition-colors whitespace-nowrap ${
isActive
? 'border-blue-600 text-blue-600 dark:text-blue-400'
: 'border-transparent text-muted-foreground hover:text-foreground'

View File

@@ -0,0 +1,145 @@
import { Bell, BellOff, BellRing, Loader2 } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import type { NotificationPreferencesState } from '../../types/types';
type NotificationsSettingsTabProps = {
notificationPreferences: NotificationPreferencesState;
onNotificationPreferencesChange: (value: NotificationPreferencesState) => void;
pushPermission: NotificationPermission | 'unsupported';
isPushSubscribed: boolean;
isPushLoading: boolean;
onEnablePush: () => void;
onDisablePush: () => void;
};
export default function NotificationsSettingsTab({
notificationPreferences,
onNotificationPreferencesChange,
pushPermission,
isPushSubscribed,
isPushLoading,
onEnablePush,
onDisablePush,
}: NotificationsSettingsTabProps) {
const { t } = useTranslation('settings');
const pushSupported = pushPermission !== 'unsupported';
const pushDenied = pushPermission === 'denied';
return (
<div className="space-y-6 md:space-y-8">
<div className="space-y-4">
<div className="flex items-center gap-3">
<Bell className="w-5 h-5 text-blue-600" />
<h3 className="text-lg font-medium text-foreground">{t('notifications.title')}</h3>
</div>
<p className="text-sm text-muted-foreground">{t('notifications.description')}</p>
</div>
<div className="space-y-4 bg-card border border-border rounded-lg p-4">
<h4 className="font-medium text-foreground">{t('notifications.webPush.title')}</h4>
{!pushSupported ? (
<p className="text-sm text-muted-foreground">{t('notifications.webPush.unsupported')}</p>
) : pushDenied ? (
<p className="text-sm text-muted-foreground">{t('notifications.webPush.denied')}</p>
) : (
<div className="flex items-center gap-3">
<button
type="button"
disabled={isPushLoading}
onClick={() => {
if (isPushSubscribed) {
onDisablePush();
} else {
onEnablePush();
}
}}
className={`inline-flex items-center gap-2 px-4 py-2 text-sm font-medium rounded-md transition-colors disabled:opacity-50 disabled:cursor-not-allowed ${
isPushSubscribed
? 'bg-red-100 text-red-700 hover:bg-red-200 dark:bg-red-900/30 dark:text-red-400 dark:hover:bg-red-900/50'
: 'bg-blue-600 text-white hover:bg-blue-700 dark:bg-blue-500 dark:hover:bg-blue-600'
}`}
>
{isPushLoading ? (
<Loader2 className="w-4 h-4 animate-spin" />
) : isPushSubscribed ? (
<BellOff className="w-4 h-4" />
) : (
<BellRing className="w-4 h-4" />
)}
{isPushLoading
? t('notifications.webPush.loading')
: isPushSubscribed
? t('notifications.webPush.disable')
: t('notifications.webPush.enable')}
</button>
{isPushSubscribed && (
<span className="text-sm text-green-600 dark:text-green-400">
{t('notifications.webPush.enabled')}
</span>
)}
</div>
)}
</div>
<div className="space-y-4 bg-card border border-border rounded-lg p-4">
<h4 className="font-medium text-foreground">{t('notifications.events.title')}</h4>
<div className="space-y-3">
<label className="flex items-center gap-2 text-sm text-foreground">
<input
type="checkbox"
checked={notificationPreferences.events.actionRequired}
onChange={(event) =>
onNotificationPreferencesChange({
...notificationPreferences,
events: {
...notificationPreferences.events,
actionRequired: event.target.checked,
},
})
}
className="w-4 h-4"
/>
{t('notifications.events.actionRequired')}
</label>
<label className="flex items-center gap-2 text-sm text-foreground">
<input
type="checkbox"
checked={notificationPreferences.events.stop}
onChange={(event) =>
onNotificationPreferencesChange({
...notificationPreferences,
events: {
...notificationPreferences.events,
stop: event.target.checked,
},
})
}
className="w-4 h-4"
/>
{t('notifications.events.stop')}
</label>
<label className="flex items-center gap-2 text-sm text-foreground">
<input
type="checkbox"
checked={notificationPreferences.events.error}
onChange={(event) =>
onNotificationPreferencesChange({
...notificationPreferences,
events: {
...notificationPreferences.events,
error: event.target.checked,
},
})
}
className="w-4 h-4"
/>
{t('notifications.events.error')}
</label>
</div>
</div>
</div>
);
}

View File

@@ -256,6 +256,7 @@ function ClaudePermissions({
<li><code className="bg-blue-100 dark:bg-blue-800 px-1 rounded">"Bash(rm:*)"</code> {t('permissions.toolExamples.bashRm')}</li>
</ul>
</div>
</div>
);
}

View File

@@ -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

View File

@@ -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}

View File

@@ -1,3 +1,4 @@
import { useEffect } from 'react';
import type { TFunction } from 'i18next';
import type { LoadingProgress, Project, ProjectSession, SessionProvider } from '../../../../types/app';
import type {
@@ -103,6 +104,15 @@ export default function SidebarProjectList({
/>
);
useEffect(() => {
let baseTitle = 'CloudCLI UI';
const displayName = selectedProject?.displayName?.trim();
if (displayName) {
baseTitle = `${displayName} - ${baseTitle}`;
}
document.title = baseTitle;
}, [selectedProject]);
const showProjects = !isLoading && projects.length > 0 && filteredProjects.length > 0;
return (

103
src/hooks/useWebPush.ts Normal file
View File

@@ -0,0 +1,103 @@
import { useCallback, useEffect, useState } from 'react';
import { authenticatedFetch } from '../utils/api';
type WebPushState = {
permission: NotificationPermission | 'unsupported';
isSubscribed: boolean;
isLoading: boolean;
subscribe: () => Promise<void>;
unsubscribe: () => Promise<void>;
};
function urlBase64ToUint8Array(base64String: string): Uint8Array {
const padding = '='.repeat((4 - (base64String.length % 4)) % 4);
const base64 = (base64String + padding).replace(/-/g, '+').replace(/_/g, '/');
const rawData = window.atob(base64);
const outputArray = new Uint8Array(rawData.length);
for (let i = 0; i < rawData.length; ++i) {
outputArray[i] = rawData.charCodeAt(i);
}
return outputArray;
}
export function useWebPush(): WebPushState {
const [permission, setPermission] = useState<NotificationPermission | 'unsupported'>(() => {
if (typeof window === 'undefined' || !('Notification' in window) || !('serviceWorker' in navigator)) {
return 'unsupported';
}
return Notification.permission;
});
const [isSubscribed, setIsSubscribed] = useState(false);
const [isLoading, setIsLoading] = useState(false);
// Check existing subscription on mount
useEffect(() => {
if (permission === 'unsupported') return;
navigator.serviceWorker.ready.then((registration) => {
registration.pushManager.getSubscription().then((sub) => {
setIsSubscribed(sub !== null);
});
}).catch(() => {
// SW not ready yet
});
}, [permission]);
const subscribe = useCallback(async () => {
if (permission === 'unsupported') return;
setIsLoading(true);
try {
const perm = await Notification.requestPermission();
setPermission(perm);
if (perm !== 'granted') return;
const keyRes = await authenticatedFetch('/api/settings/push/vapid-public-key');
const { publicKey } = await keyRes.json();
const registration = await navigator.serviceWorker.ready;
const subscription = await registration.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: urlBase64ToUint8Array(publicKey).buffer as ArrayBuffer,
});
const subJson = subscription.toJSON();
await authenticatedFetch('/api/settings/push/subscribe', {
method: 'POST',
body: JSON.stringify({
endpoint: subJson.endpoint,
keys: subJson.keys,
}),
});
setIsSubscribed(true);
} catch (err) {
console.error('Push subscribe failed:', err);
} finally {
setIsLoading(false);
}
}, [permission]);
const unsubscribe = useCallback(async () => {
setIsLoading(true);
try {
const registration = await navigator.serviceWorker.ready;
const subscription = await registration.pushManager.getSubscription();
if (subscription) {
const endpoint = subscription.endpoint;
await subscription.unsubscribe();
await authenticatedFetch('/api/settings/push/unsubscribe', {
method: 'POST',
body: JSON.stringify({ endpoint }),
});
}
setIsSubscribed(false);
} catch (err) {
console.error('Push unsubscribe failed:', err);
} finally {
setIsLoading(false);
}
}, []);
return { permission, isSubscribed, isLoading, subscribe, unsubscribe };
}

View File

@@ -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."
}
}

View File

@@ -114,7 +114,11 @@
"justNow": "just now",
"minAgo": "{{count}} min ago",
"hoursAgo": "{{count}} hours ago",
"daysAgo": "{{count}} days ago"
"daysAgo": "{{count}} days ago",
"newFile": "New File (Cmd+N)",
"newFolder": "New Folder (Cmd+Shift+N)",
"refresh": "Refresh",
"collapseAll": "Collapse All"
},
"projectWizard": {
"title": "Create New Project",
@@ -191,6 +195,36 @@
"failedToCreateFolder": "Failed to create folder"
}
},
"notifications": {
"genericTool": "a tool",
"codes": {
"generic": {
"info": {
"title": "Notification"
}
},
"permission": {
"required": {
"title": "Action Required",
"body": "{{toolName}} is waiting for your decision."
}
},
"run": {
"stopped": {
"title": "Run Stopped",
"body": "Reason: {{reason}}"
},
"failed": {
"title": "Run Failed"
}
},
"agent": {
"notification": {
"title": "Agent Notification"
}
}
}
},
"versionUpdate": {
"title": "Update Available",
"newVersionReady": "A new version is ready",

View File

@@ -84,11 +84,32 @@
}
},
"mainTabs": {
"label": "Settings",
"agents": "Agents",
"appearance": "Appearance",
"git": "Git",
"apiTokens": "API & Tokens",
"tasks": "Tasks"
"tasks": "Tasks",
"notifications": "Notifications"
},
"notifications": {
"title": "Notifications",
"description": "Control which notification events you receive.",
"webPush": {
"title": "Web Push Notifications",
"enable": "Enable Push Notifications",
"disable": "Disable Push Notifications",
"enabled": "Push notifications are enabled",
"loading": "Updating...",
"unsupported": "Push notifications are not supported in this browser.",
"denied": "Push notifications are blocked. Please allow them in your browser settings."
},
"events": {
"title": "Event Types",
"actionRequired": "Action required",
"stop": "Run stopped",
"error": "Run failed"
}
},
"appearanceSettings": {
"darkMode": {

View File

@@ -63,7 +63,8 @@
"cancel": "Cancel",
"save": "Save",
"delete": "Delete",
"rename": "Rename"
"rename": "Rename",
"joinCommunity": "Join Community"
},
"status": {
"active": "Active",

View File

@@ -26,5 +26,9 @@
"lines": "行数:",
"characters": "文字数:",
"shortcuts": "Ctrl+Sで保存 • Escで閉じる"
},
"binaryFile": {
"title": "バイナリファイル",
"message": "ファイル \"{{fileName}}\" はバイナリファイルのため、テキストエディタで表示できません。"
}
}

View File

@@ -114,7 +114,11 @@
"justNow": "たった今",
"minAgo": "{{count}}分前",
"hoursAgo": "{{count}}時間前",
"daysAgo": "{{count}}日前"
"daysAgo": "{{count}}日前",
"newFile": "新規ファイル (Cmd+N)",
"newFolder": "新規フォルダ (Cmd+Shift+N)",
"refresh": "更新",
"collapseAll": "すべて折りたたむ"
},
"projectWizard": {
"title": "新規プロジェクトを作成",
@@ -191,6 +195,36 @@
"failedToCreateFolder": "フォルダの作成に失敗しました"
}
},
"notifications": {
"genericTool": "ツール",
"codes": {
"generic": {
"info": {
"title": "通知"
}
},
"permission": {
"required": {
"title": "対応が必要です",
"body": "{{toolName}} があなたの判断を待っています。"
}
},
"run": {
"stopped": {
"title": "実行が停止しました",
"body": "理由: {{reason}}"
},
"failed": {
"title": "実行に失敗しました"
}
},
"agent": {
"notification": {
"title": "エージェント通知"
}
}
}
},
"versionUpdate": {
"title": "アップデートのお知らせ",
"newVersionReady": "新しいバージョンが利用可能です",

View File

@@ -84,11 +84,32 @@
}
},
"mainTabs": {
"label": "設定",
"agents": "エージェント",
"appearance": "外観",
"git": "Git",
"apiTokens": "API & トークン",
"tasks": "タスク"
"tasks": "タスク",
"notifications": "通知"
},
"notifications": {
"title": "通知",
"description": "受信する通知イベントを設定します。",
"webPush": {
"title": "Webプッシュ通知",
"enable": "プッシュ通知を有効にする",
"disable": "プッシュ通知を無効にする",
"enabled": "プッシュ通知は有効です",
"loading": "更新中...",
"unsupported": "このブラウザではプッシュ通知がサポートされていません。",
"denied": "プッシュ通知がブロックされています。ブラウザの設定で許可してください。"
},
"events": {
"title": "イベント種別",
"actionRequired": "対応が必要",
"stop": "実行停止",
"error": "実行失敗"
}
},
"appearanceSettings": {
"darkMode": {

View File

@@ -63,7 +63,8 @@
"cancel": "キャンセル",
"save": "保存",
"delete": "削除",
"rename": "名前の変更"
"rename": "名前の変更",
"joinCommunity": "コミュニティに参加"
},
"status": {
"active": "アクティブ",

View File

@@ -26,5 +26,9 @@
"lines": "줄:",
"characters": "문자:",
"shortcuts": "Ctrl+S로 저장 • Esc로 닫기"
},
"binaryFile": {
"title": "바이너리 파일",
"message": "파일 \"{{fileName}}\"은(는) 바이너리 파일이므로 텍스트 편집기에서 표시할 수 없습니다."
}
}

View File

@@ -114,7 +114,11 @@
"justNow": "방금 전",
"minAgo": "{{count}}분 전",
"hoursAgo": "{{count}}시간 전",
"daysAgo": "{{count}}일 전"
"daysAgo": "{{count}}일 전",
"newFile": "새 파일 (Cmd+N)",
"newFolder": "새 폴더 (Cmd+Shift+N)",
"refresh": "새로고침",
"collapseAll": "모두 접기"
},
"projectWizard": {
"title": "새 프로젝트 생성",
@@ -191,6 +195,36 @@
"failedToCreateFolder": "폴더 생성 실패"
}
},
"notifications": {
"genericTool": "도구",
"codes": {
"generic": {
"info": {
"title": "알림"
}
},
"permission": {
"required": {
"title": "작업 필요",
"body": "{{toolName}} 에 대한 결정을 기다리고 있습니다."
}
},
"run": {
"stopped": {
"title": "실행이 중지되었습니다",
"body": "사유: {{reason}}"
},
"failed": {
"title": "실행 실패"
}
},
"agent": {
"notification": {
"title": "에이전트 알림"
}
}
}
},
"versionUpdate": {
"title": "업데이트 가능",
"newVersionReady": "새 버전이 준비되었습니다",

View File

@@ -84,11 +84,32 @@
}
},
"mainTabs": {
"label": "설정",
"agents": "에이전트",
"appearance": "외관",
"git": "Git",
"apiTokens": "API & 토큰",
"tasks": "작업"
"tasks": "작업",
"notifications": "알림"
},
"notifications": {
"title": "알림",
"description": "수신할 알림 이벤트를 설정합니다.",
"webPush": {
"title": "웹 푸시 알림",
"enable": "푸시 알림 활성화",
"disable": "푸시 알림 비활성화",
"enabled": "푸시 알림이 활성화되었습니다",
"loading": "업데이트 중...",
"unsupported": "이 브라우저에서는 푸시 알림이 지원되지 않습니다.",
"denied": "푸시 알림이 차단되었습니다. 브라우저 설정에서 허용해 주세요."
},
"events": {
"title": "이벤트 유형",
"actionRequired": "작업 필요",
"stop": "실행 중지",
"error": "실행 실패"
}
},
"appearanceSettings": {
"darkMode": {

View File

@@ -63,7 +63,8 @@
"cancel": "취소",
"save": "저장",
"delete": "삭제",
"rename": "이름 변경"
"rename": "이름 변경",
"joinCommunity": "커뮤니티 참여"
},
"status": {
"active": "활성",

View File

@@ -26,5 +26,9 @@
"lines": "行数:",
"characters": "字符数:",
"shortcuts": "按 Ctrl+S 保存 • Esc 关闭"
},
"binaryFile": {
"title": "二进制文件",
"message": "文件 \"{{fileName}}\" 无法在文本编辑器中显示,因为它是二进制文件。"
}
}

View File

@@ -114,7 +114,11 @@
"justNow": "刚刚",
"minAgo": "{{count}} 分钟前",
"hoursAgo": "{{count}} 小时前",
"daysAgo": "{{count}} 天前"
"daysAgo": "{{count}} 天前",
"newFile": "新建文件 (Cmd+N)",
"newFolder": "新建文件夹 (Cmd+Shift+N)",
"refresh": "刷新",
"collapseAll": "全部折叠"
},
"projectWizard": {
"title": "创建新项目",
@@ -191,6 +195,36 @@
"failedToCreateFolder": "创建文件夹失败"
}
},
"notifications": {
"genericTool": "工具",
"codes": {
"generic": {
"info": {
"title": "通知"
}
},
"permission": {
"required": {
"title": "需要处理",
"body": "{{toolName}} 正在等待你的决策。"
}
},
"run": {
"stopped": {
"title": "运行已停止",
"body": "原因:{{reason}}"
},
"failed": {
"title": "运行失败"
}
},
"agent": {
"notification": {
"title": "Agent 通知"
}
}
}
},
"versionUpdate": {
"title": "有可用更新",
"newVersionReady": "新版本已准备就绪",

View File

@@ -84,11 +84,32 @@
}
},
"mainTabs": {
"label": "设置",
"agents": "智能体",
"appearance": "外观",
"git": "Git",
"apiTokens": "API 和令牌",
"tasks": "任务"
"tasks": "任务",
"notifications": "通知"
},
"notifications": {
"title": "通知",
"description": "控制你希望接收的通知事件。",
"webPush": {
"title": "Web 推送通知",
"enable": "启用推送通知",
"disable": "关闭推送通知",
"enabled": "推送通知已启用",
"loading": "更新中...",
"unsupported": "此浏览器不支持推送通知。",
"denied": "推送通知已被阻止,请在浏览器设置中允许。"
},
"events": {
"title": "事件类型",
"actionRequired": "需要处理",
"stop": "运行已停止",
"error": "运行失败"
}
},
"appearanceSettings": {
"darkMode": {

View File

@@ -63,7 +63,8 @@
"cancel": "取消",
"save": "保存",
"delete": "删除",
"rename": "重命名"
"rename": "重命名",
"joinCommunity": "加入社区"
},
"status": {
"active": "活动",

View File

@@ -7,14 +7,10 @@ import 'katex/dist/katex.min.css'
// Initialize i18n
import './i18n/config.js'
// Clean up stale service workers on app load to prevent caching issues after builds
// Register service worker for PWA + Web Push support
if ('serviceWorker' in navigator) {
navigator.serviceWorker.getRegistrations().then(registrations => {
registrations.forEach(registration => {
registration.unregister();
});
}).catch(err => {
console.warn('Failed to unregister service workers:', err);
navigator.serviceWorker.register('/sw.js').catch(err => {
console.warn('Service worker registration failed:', err);
});
}

View File

@@ -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,
}),
};