Compare commits
61 Commits
v0.13.1-de
...
v0.13.3-de
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d0a0325d7e | ||
|
|
19a4c3df16 | ||
|
|
10506920ac | ||
|
|
92c029d744 | ||
|
|
6eb3246d37 | ||
|
|
5c90de84de | ||
|
|
455a59f693 | ||
|
|
a89da02d6b | ||
|
|
69d9e95bee | ||
|
|
893d5f9296 | ||
|
|
e82e529a8f | ||
|
|
4f236ce36f | ||
|
|
2ffeb45a9c | ||
|
|
df16b64a95 | ||
|
|
f3c54df283 | ||
|
|
5658a9f62d | ||
|
|
9d6a5bcdc0 | ||
|
|
514b187b00 | ||
|
|
240acb7729 | ||
|
|
278b563c1a | ||
|
|
0af79002ed | ||
|
|
f3981a1cce | ||
|
|
031e8d5717 | ||
|
|
995fb3b6a3 | ||
|
|
aeb0ff11b3 | ||
|
|
b61cfbd9f9 | ||
|
|
481dd1a88a | ||
|
|
3f6cdd36f3 | ||
|
|
fe932c8307 | ||
|
|
64ac885157 | ||
|
|
1d953dfe64 | ||
|
|
42589464e5 | ||
|
|
27bccb8d6b | ||
|
|
153065d025 | ||
|
|
2abda0e6b4 | ||
|
|
800133361d | ||
|
|
034cb5dea9 | ||
|
|
d7ab84f245 | ||
|
|
201988b97c | ||
|
|
6a6fcff2c8 | ||
|
|
f29f197b9a | ||
|
|
dbde403b3e | ||
|
|
230c981cc2 | ||
|
|
34978c87fb | ||
|
|
3e6d0a402c | ||
|
|
e81c5f6443 | ||
|
|
b0d27bd127 | ||
|
|
7576470295 | ||
|
|
6d32e09db0 | ||
|
|
503cb3a02e | ||
|
|
0250c6350f | ||
|
|
24cc8fe939 | ||
|
|
282b234a7c | ||
|
|
4ba088a876 | ||
|
|
7b1817d606 | ||
|
|
5bc3c23ec5 | ||
|
|
127a51e3c3 | ||
|
|
daa22b6d8c | ||
|
|
23f2de2d7e | ||
|
|
80c9b76709 | ||
|
|
a29b77d60b |
5
.github/workflows/comment-pr-artifacts.yml
vendored
5
.github/workflows/comment-pr-artifacts.yml
vendored
@@ -4,6 +4,7 @@ on:
|
|||||||
pull_request_target:
|
pull_request_target:
|
||||||
types:
|
types:
|
||||||
- opened
|
- opened
|
||||||
|
- edited
|
||||||
- synchronize
|
- synchronize
|
||||||
- reopened
|
- reopened
|
||||||
- ready_for_review
|
- ready_for_review
|
||||||
@@ -19,7 +20,7 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
env:
|
env:
|
||||||
ALLOWED_ACTORS: ${{ vars.ALLOWED_NON_DEV_PR_ACTORS }}
|
ALLOWED_ACTORS: ${{ vars.ALLOWED_NON_DEV_PR_ACTORS }}
|
||||||
ACTOR: ${{ github.actor }}
|
PR_AUTHOR: ${{ github.event.pull_request.user.login }}
|
||||||
BASE_REF: ${{ github.event.pull_request.base.ref }}
|
BASE_REF: ${{ github.event.pull_request.base.ref }}
|
||||||
IS_DRAFT: ${{ github.event.pull_request.draft }}
|
IS_DRAFT: ${{ github.event.pull_request.draft }}
|
||||||
PR_NUMBER: ${{ github.event.pull_request.number }}
|
PR_NUMBER: ${{ github.event.pull_request.number }}
|
||||||
@@ -37,7 +38,7 @@ jobs:
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
normalized=",${ALLOWED_ACTORS},"
|
normalized=",${ALLOWED_ACTORS},"
|
||||||
if [[ "$normalized" == *",${ACTOR},"* ]]; then
|
if [[ "$normalized" == *",${PR_AUTHOR},"* ]]; then
|
||||||
echo "allowed=true" >> "$GITHUB_OUTPUT"
|
echo "allowed=true" >> "$GITHUB_OUTPUT"
|
||||||
else
|
else
|
||||||
echo "allowed=false" >> "$GITHUB_OUTPUT"
|
echo "allowed=false" >> "$GITHUB_OUTPUT"
|
||||||
|
|||||||
7
.github/workflows/pr-build.yml
vendored
7
.github/workflows/pr-build.yml
vendored
@@ -4,6 +4,7 @@ on:
|
|||||||
pull_request:
|
pull_request:
|
||||||
types:
|
types:
|
||||||
- opened
|
- opened
|
||||||
|
- edited
|
||||||
- synchronize
|
- synchronize
|
||||||
- reopened
|
- reopened
|
||||||
- ready_for_review
|
- ready_for_review
|
||||||
@@ -23,7 +24,7 @@ jobs:
|
|||||||
allowed: ${{ steps.auth.outputs.allowed }}
|
allowed: ${{ steps.auth.outputs.allowed }}
|
||||||
env:
|
env:
|
||||||
ALLOWED_ACTORS: ${{ vars.ALLOWED_NON_DEV_PR_ACTORS }}
|
ALLOWED_ACTORS: ${{ vars.ALLOWED_NON_DEV_PR_ACTORS }}
|
||||||
ACTOR: ${{ github.actor }}
|
PR_AUTHOR: ${{ github.event.pull_request.user.login }}
|
||||||
BASE_REF: ${{ github.event.pull_request.base.ref }}
|
BASE_REF: ${{ github.event.pull_request.base.ref }}
|
||||||
steps:
|
steps:
|
||||||
- name: Check PR authorization
|
- name: Check PR authorization
|
||||||
@@ -37,11 +38,11 @@ jobs:
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
normalized=",${ALLOWED_ACTORS},"
|
normalized=",${ALLOWED_ACTORS},"
|
||||||
if [[ "$normalized" == *",${ACTOR},"* ]]; then
|
if [[ "$normalized" == *",${PR_AUTHOR},"* ]]; then
|
||||||
echo "allowed=true" >> "$GITHUB_OUTPUT"
|
echo "allowed=true" >> "$GITHUB_OUTPUT"
|
||||||
else
|
else
|
||||||
echo "allowed=false" >> "$GITHUB_OUTPUT"
|
echo "allowed=false" >> "$GITHUB_OUTPUT"
|
||||||
echo "Skipping builds for unauthorized PR targeting $BASE_REF" >&2
|
echo "Skipping builds for PR by unauthorized author targeting $BASE_REF" >&2
|
||||||
fi
|
fi
|
||||||
|
|
||||||
build:
|
build:
|
||||||
|
|||||||
7
.github/workflows/restrict-non-dev-prs.yml
vendored
7
.github/workflows/restrict-non-dev-prs.yml
vendored
@@ -4,6 +4,7 @@ on:
|
|||||||
pull_request_target:
|
pull_request_target:
|
||||||
types:
|
types:
|
||||||
- opened
|
- opened
|
||||||
|
- edited
|
||||||
- reopened
|
- reopened
|
||||||
- synchronize
|
- synchronize
|
||||||
|
|
||||||
@@ -17,7 +18,7 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
env:
|
env:
|
||||||
ALLOWED_ACTORS: ${{ vars.ALLOWED_NON_DEV_PR_ACTORS }}
|
ALLOWED_ACTORS: ${{ vars.ALLOWED_NON_DEV_PR_ACTORS }}
|
||||||
ACTOR: ${{ github.actor }}
|
PR_AUTHOR: ${{ github.event.pull_request.user.login }}
|
||||||
PR_NUMBER: ${{ github.event.pull_request.number }}
|
PR_NUMBER: ${{ github.event.pull_request.number }}
|
||||||
BASE_REF: ${{ github.event.pull_request.base.ref }}
|
BASE_REF: ${{ github.event.pull_request.base.ref }}
|
||||||
steps:
|
steps:
|
||||||
@@ -27,7 +28,7 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
normalized=",${ALLOWED_ACTORS},"
|
normalized=",${ALLOWED_ACTORS},"
|
||||||
if [[ "$normalized" == *",${ACTOR},"* ]]; then
|
if [[ "$normalized" == *",${PR_AUTHOR},"* ]]; then
|
||||||
echo "authorized=true" >> "$GITHUB_OUTPUT"
|
echo "authorized=true" >> "$GITHUB_OUTPUT"
|
||||||
else
|
else
|
||||||
echo "authorized=false" >> "$GITHUB_OUTPUT"
|
echo "authorized=false" >> "$GITHUB_OUTPUT"
|
||||||
@@ -50,5 +51,5 @@ jobs:
|
|||||||
- name: Fail unauthorized PR
|
- name: Fail unauthorized PR
|
||||||
if: ${{ steps.auth.outputs.authorized != 'true' }}
|
if: ${{ steps.auth.outputs.authorized != 'true' }}
|
||||||
run: |
|
run: |
|
||||||
echo "Actor $ACTOR is not allowed to open PRs targeting $BASE_REF" >&2
|
echo "PR author $PR_AUTHOR is not allowed to open PRs targeting $BASE_REF" >&2
|
||||||
exit 1
|
exit 1
|
||||||
|
|||||||
171
README.md
171
README.md
@@ -1,128 +1,127 @@
|
|||||||
# CodeNomad
|
# CodeNomad
|
||||||
|
|
||||||
## A fast, multi-instance workspace for running OpenCode sessions.
|
## The AI Coding Cockpit for OpenCode
|
||||||
|
|
||||||
CodeNomad is built for people who live inside OpenCode for hours on end and need a cockpit, not a kiosk. It delivers a premium, low-latency workspace that favors speed, clarity, and direct control.
|
CodeNomad transforms OpenCode from a terminal tool into a **premium desktop workspace** — built for developers who live inside AI coding sessions for hours and need control, speed, and clarity.
|
||||||
|
|
||||||
|
> OpenCode gives you the engine. CodeNomad gives you the cockpit.
|
||||||
|
|
||||||

|

|
||||||
_Manage multiple OpenCode sessions side-by-side._
|
|
||||||
|
|
||||||
<details>
|
---
|
||||||
<summary>📸 More Screenshots</summary>
|
|
||||||
|
|
||||||

|
## Features
|
||||||
_Global command palette for keyboard-first control._
|
|
||||||
|
|
||||||

|
- **🚀 Multi-Instance Workspace**
|
||||||
_Rich media previews for images and assets._
|
- **🌐 Remote Access**
|
||||||
|
- **🧠 Session Management**
|
||||||
|
- **🎙️ Voice Input & Speech**
|
||||||
|
- **🌳 Git Worktrees**
|
||||||
|
- **💬 Rich Message Experience**
|
||||||
|
- **⌨️ Command Palette**
|
||||||
|
- **📁 File System Browser**
|
||||||
|
- **🔐 Authentication & Security**
|
||||||
|
- **🔔 Notifications**
|
||||||
|
- **🎨 Theming**
|
||||||
|
- **🌍 Internationalization**
|
||||||
|
|
||||||

|
---
|
||||||
_Browser support via CodeNomad Server._
|
|
||||||
|
|
||||||
</details>
|
|
||||||
|
|
||||||
## Getting Started
|
## Getting Started
|
||||||
|
|
||||||
Choose the way that fits your workflow:
|
### 🖥️ Desktop App
|
||||||
|
|
||||||
### 🖥️ Desktop App (Recommended)
|
Available as both Electron and Tauri builds — choose based on your preference.
|
||||||
The best experience. A native application (Electron-based) with global shortcuts, deeper system integration, and a dedicated window.
|
|
||||||
|
|
||||||
- **Download**: Grab the latest installer for macOS, Windows, or Linux from the [Releases Page](https://github.com/shantur/CodeNomad/releases).
|
Download the latest installer for your platform from [Releases](https://github.com/shantur/CodeNomad/releases).
|
||||||
- **Run**: Install and launch like any other app.
|
|
||||||
|
|
||||||
### 🦀 Tauri App (Experimental)
|
| Platform | Formats |
|
||||||
We are also working on a lightweight, high-performance version built with [Tauri](https://tauri.app). It is currently in active development.
|
|----------|---------|
|
||||||
|
| macOS | DMG, ZIP (Universal: Intel + Apple Silicon) |
|
||||||
- **Download**: Experimental builds are available on the [Releases Page](https://github.com/shantur/CodeNomad/releases).
|
| Windows | NSIS Installer, ZIP (x64, ARM64) |
|
||||||
- **Source**: Check out `packages/tauri-app` if you're interested in contributing.
|
| Linux | AppImage, deb, tar.gz (x64, ARM64) |
|
||||||
|
|
||||||
### 💻 CodeNomad Server
|
### 💻 CodeNomad Server
|
||||||
Run CodeNomad as a local server and access it via your web browser. Perfect for remote development (SSH/VPN) or running as a service.
|
|
||||||
|
Run as a local server and access via browser. Perfect for remote development.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npx @neuralnomads/codenomad --launch
|
npx @neuralnomads/codenomad --launch
|
||||||
```
|
```
|
||||||
|
|
||||||
Full server/CLI documentation (flags + env vars, TLS, auth, remote access):
|
See [Server Documentation](packages/server/README.md) for flags, TLS, auth, and remote access.
|
||||||
- [packages/server/README.md](packages/server/README.md)
|
|
||||||
|
|
||||||
To see all available options:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
npx @neuralnomads/codenomad --help
|
|
||||||
```
|
|
||||||
|
|
||||||
### 🧪 Dev Releases
|
### 🧪 Dev Releases
|
||||||
Bleeding-edge builds are published as GitHub pre-releases and are generated automatically from the `dev` branch.
|
|
||||||
|
Bleeding-edge builds from the `dev` branch:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npx @neuralnomads/codenomad-dev --launch
|
npx @neuralnomads/codenomad-dev --launch
|
||||||
```
|
```
|
||||||
|
|
||||||
## Highlights
|
---
|
||||||
|
|
||||||
- **Multi-Instance**: Juggle several OpenCode sessions side-by-side with tabs.
|
|
||||||
- **Long-Session Native**: Scroll through massive transcripts without hitches.
|
|
||||||
- **Command Palette**: A single global palette to jump tabs, launch tools, and control everything.
|
|
||||||
- **Deep Task Awareness**: Monitor background tasks and child sessions without losing flow.
|
|
||||||
|
|
||||||
## Requirements
|
## Requirements
|
||||||
|
|
||||||
- **[OpenCode CLI](https://opencode.ai)**: Must be installed and available in your `PATH`.
|
- **[OpenCode CLI](https://opencode.ai)** — must be installed and in your `PATH`
|
||||||
- **Node.js 18+**: Required if running the CLI server or building from source.
|
- **Node.js 18+** — for server mode or building from source
|
||||||
|
|
||||||
## Troubleshooting
|
---
|
||||||
|
|
||||||
### macOS says the app is damaged
|
## Development
|
||||||
If macOS reports that "CodeNomad.app is damaged and can't be opened," Gatekeeper flagged the download because the app is not yet notarized. You can clear the quarantine flag after moving CodeNomad into `/Applications`:
|
|
||||||
|
|
||||||
```bash
|
CodeNomad is a monorepo built with:
|
||||||
xattr -l /Applications/CodeNomad.app
|
|
||||||
xattr -dr com.apple.quarantine /Applications/CodeNomad.app
|
|
||||||
```
|
|
||||||
|
|
||||||
After removing the quarantine attribute, launch the app normally. On Intel Macs you may also need to approve CodeNomad from **System Settings → Privacy & Security** the first time you run it.
|
|
||||||
|
|
||||||
### Linux (Wayland + NVIDIA): Tauri AppImage closes immediately
|
|
||||||
On some Wayland compositor + NVIDIA driver setups, WebKitGTK can fail to initialize its DMA-BUF/GBM path and the Tauri build may exit right away.
|
|
||||||
|
|
||||||
Try running with one of these environment variables:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Most reliable workaround (can reduce rendering performance)
|
|
||||||
WEBKIT_DISABLE_DMABUF_RENDERER=1 codenomad
|
|
||||||
|
|
||||||
# Alternative for some Wayland setups
|
|
||||||
__NV_DISABLE_EXPLICIT_SYNC=1 codenomad
|
|
||||||
```
|
|
||||||
|
|
||||||
If you're running the Tauri AppImage and want the workaround applied every time, create a tiny wrapper script on your `PATH`:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
#!/bin/bash
|
|
||||||
export WEBKIT_DISABLE_DMABUF_RENDERER=1
|
|
||||||
exec ~/.local/share/bauh/appimage/installed/codenomad/CodeNomad-Tauri-0.4.0-linux-x64.AppImage "$@"
|
|
||||||
```
|
|
||||||
|
|
||||||
Upstream tracking: https://github.com/tauri-apps/tauri/issues/10702
|
|
||||||
|
|
||||||
## Architecture & Development
|
|
||||||
|
|
||||||
CodeNomad is a monorepo split into specialized packages. If you want to contribute or build from source, check out the individual package documentation:
|
|
||||||
|
|
||||||
| Package | Description |
|
| Package | Description |
|
||||||
|---------|-------------|
|
|---------|-------------|
|
||||||
| **[packages/electron-app](packages/electron-app/README.md)** | The native desktop application shell. Wraps the UI and Server. |
|
| **[packages/server](packages/server/README.md)** | Core logic & CLI — workspaces, OpenCode proxy, API, auth, speech |
|
||||||
| **[packages/server](packages/server/README.md)** | The core logic and CLI. Manages workspaces, proxies OpenCode, and serves the API. |
|
| **[packages/ui](packages/ui/README.md)** | SolidJS frontend — reactive, fast, beautiful |
|
||||||
| **[packages/ui](packages/ui/README.md)** | The SolidJS-based frontend. Fast, reactive, and beautiful. |
|
| **[packages/electron-app](packages/electron-app/README.md)** | Desktop shell — process management, IPC, native dialogs |
|
||||||
|
| **[packages/tauri-app](packages/tauri-app)** | Tauri desktop shell (experimental) |
|
||||||
|
|
||||||
### Quick Build
|
### Quick Start
|
||||||
To build the Desktop App from source:
|
|
||||||
|
|
||||||
1. Clone the repo.
|
```bash
|
||||||
2. Run `npm install` (requires pnpm or npm 7+ for workspaces).
|
git clone https://github.com/NeuralNomadsAI/CodeNomad.git
|
||||||
3. Run `npm run build --workspace @neuralnomads/codenomad-electron-app`.
|
cd CodeNomad
|
||||||
|
npm install
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
[](https://star-history.com/#NeuralNomadsAI/CodeNomad&Date)
|
---
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary><strong>macOS: "CodeNomad.app is damaged and can't be opened"</strong></summary>
|
||||||
|
|
||||||
|
Gatekeeper flag due to missing notarization. Clear the quarantine attribute:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
xattr -dr com.apple.quarantine /Applications/CodeNomad.app
|
||||||
|
```
|
||||||
|
|
||||||
|
On Intel Macs, also check **System Settings → Privacy & Security** on first launch.
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary><strong>Linux (Wayland + NVIDIA): Tauri App closes immediately</strong></summary>
|
||||||
|
|
||||||
|
WebKitGTK DMA-BUF/GBM issue. Run with:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
WEBKIT_DISABLE_DMABUF_RENDERER=1 codenomad
|
||||||
|
```
|
||||||
|
|
||||||
|
See full workaround in the original README.
|
||||||
|
</details>
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Community
|
||||||
|
|
||||||
|
[](https://star-history.com/#NeuralNomadsAI/CodeNomad&Date)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Built with ♥ by [Neural Nomads](https://github.com/NeuralNomadsAI)** · [MIT License](LICENSE)
|
||||||
|
|||||||
Binary file not shown.
|
Before Width: | Height: | Size: 845 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 835 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 1.4 MiB |
Binary file not shown.
|
Before Width: | Height: | Size: 966 KiB After Width: | Height: | Size: 1.1 MiB |
81
package-lock.json
generated
81
package-lock.json
generated
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "codenomad-workspace",
|
"name": "codenomad-workspace",
|
||||||
"version": "0.13.1",
|
"version": "0.13.3",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "codenomad-workspace",
|
"name": "codenomad-workspace",
|
||||||
"version": "0.13.1",
|
"version": "0.13.3",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"7zip-bin": "^5.2.0",
|
"7zip-bin": "^5.2.0",
|
||||||
@@ -64,7 +64,6 @@
|
|||||||
"version": "7.28.5",
|
"version": "7.28.5",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@babel/code-frame": "^7.27.1",
|
"@babel/code-frame": "^7.27.1",
|
||||||
"@babel/generator": "^7.28.5",
|
"@babel/generator": "^7.28.5",
|
||||||
@@ -3381,7 +3380,6 @@
|
|||||||
"version": "7.20.5",
|
"version": "7.20.5",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@babel/parser": "^7.20.7",
|
"@babel/parser": "^7.20.7",
|
||||||
"@babel/types": "^7.20.7",
|
"@babel/types": "^7.20.7",
|
||||||
@@ -3483,7 +3481,6 @@
|
|||||||
"version": "22.19.0",
|
"version": "22.19.0",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"undici-types": "~6.21.0"
|
"undici-types": "~6.21.0"
|
||||||
}
|
}
|
||||||
@@ -3558,7 +3555,6 @@
|
|||||||
"integrity": "sha512-MCbrb508JZHqe7bUibmZj/lyojdhLRnfkmyXnkrCM2zVrjTgL89U8UEfInpKTvPeTnxsw2hmyZxnhsdNR6yhwg==",
|
"integrity": "sha512-MCbrb508JZHqe7bUibmZj/lyojdhLRnfkmyXnkrCM2zVrjTgL89U8UEfInpKTvPeTnxsw2hmyZxnhsdNR6yhwg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"cac": "^6.7.14",
|
"cac": "^6.7.14",
|
||||||
"colorette": "^2.0.20",
|
"colorette": "^2.0.20",
|
||||||
@@ -3641,7 +3637,6 @@
|
|||||||
"version": "6.12.6",
|
"version": "6.12.6",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"fast-deep-equal": "^3.1.1",
|
"fast-deep-equal": "^3.1.1",
|
||||||
"fast-json-stable-stringify": "^2.0.0",
|
"fast-json-stable-stringify": "^2.0.0",
|
||||||
@@ -3844,6 +3839,7 @@
|
|||||||
"version": "5.3.2",
|
"version": "5.3.2",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"archiver-utils": "^2.1.0",
|
"archiver-utils": "^2.1.0",
|
||||||
"async": "^3.2.4",
|
"async": "^3.2.4",
|
||||||
@@ -3861,6 +3857,7 @@
|
|||||||
"version": "2.1.0",
|
"version": "2.1.0",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"glob": "^7.1.4",
|
"glob": "^7.1.4",
|
||||||
"graceful-fs": "^4.2.0",
|
"graceful-fs": "^4.2.0",
|
||||||
@@ -3881,6 +3878,7 @@
|
|||||||
"version": "2.3.8",
|
"version": "2.3.8",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"core-util-is": "~1.0.0",
|
"core-util-is": "~1.0.0",
|
||||||
"inherits": "~2.0.3",
|
"inherits": "~2.0.3",
|
||||||
@@ -3894,12 +3892,14 @@
|
|||||||
"node_modules/archiver-utils/node_modules/safe-buffer": {
|
"node_modules/archiver-utils/node_modules/safe-buffer": {
|
||||||
"version": "5.1.2",
|
"version": "5.1.2",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT",
|
||||||
|
"peer": true
|
||||||
},
|
},
|
||||||
"node_modules/archiver-utils/node_modules/string_decoder": {
|
"node_modules/archiver-utils/node_modules/string_decoder": {
|
||||||
"version": "1.1.1",
|
"version": "1.1.1",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"safe-buffer": "~5.1.0"
|
"safe-buffer": "~5.1.0"
|
||||||
}
|
}
|
||||||
@@ -4213,6 +4213,7 @@
|
|||||||
"version": "4.1.0",
|
"version": "4.1.0",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"buffer": "^5.5.0",
|
"buffer": "^5.5.0",
|
||||||
"inherits": "^2.0.4",
|
"inherits": "^2.0.4",
|
||||||
@@ -4276,7 +4277,6 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"baseline-browser-mapping": "^2.9.0",
|
"baseline-browser-mapping": "^2.9.0",
|
||||||
"caniuse-lite": "^1.0.30001759",
|
"caniuse-lite": "^1.0.30001759",
|
||||||
@@ -4767,6 +4767,7 @@
|
|||||||
"version": "4.1.2",
|
"version": "4.1.2",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"buffer-crc32": "^0.2.13",
|
"buffer-crc32": "^0.2.13",
|
||||||
"crc32-stream": "^4.0.2",
|
"crc32-stream": "^4.0.2",
|
||||||
@@ -4896,6 +4897,7 @@
|
|||||||
"version": "1.2.2",
|
"version": "1.2.2",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
|
"peer": true,
|
||||||
"bin": {
|
"bin": {
|
||||||
"crc32": "bin/crc32.njs"
|
"crc32": "bin/crc32.njs"
|
||||||
},
|
},
|
||||||
@@ -4907,6 +4909,7 @@
|
|||||||
"version": "4.0.3",
|
"version": "4.0.3",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"crc-32": "^1.2.0",
|
"crc-32": "^1.2.0",
|
||||||
"readable-stream": "^3.4.0"
|
"readable-stream": "^3.4.0"
|
||||||
@@ -5272,7 +5275,6 @@
|
|||||||
"version": "24.13.3",
|
"version": "24.13.3",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"app-builder-lib": "24.13.3",
|
"app-builder-lib": "24.13.3",
|
||||||
"builder-util": "24.13.1",
|
"builder-util": "24.13.1",
|
||||||
@@ -5439,6 +5441,7 @@
|
|||||||
"version": "24.13.3",
|
"version": "24.13.3",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"app-builder-lib": "24.13.3",
|
"app-builder-lib": "24.13.3",
|
||||||
"archiver": "^5.3.1",
|
"archiver": "^5.3.1",
|
||||||
@@ -5450,6 +5453,7 @@
|
|||||||
"version": "10.1.0",
|
"version": "10.1.0",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"graceful-fs": "^4.2.0",
|
"graceful-fs": "^4.2.0",
|
||||||
"jsonfile": "^6.0.1",
|
"jsonfile": "^6.0.1",
|
||||||
@@ -5463,6 +5467,7 @@
|
|||||||
"version": "6.2.0",
|
"version": "6.2.0",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"universalify": "^2.0.0"
|
"universalify": "^2.0.0"
|
||||||
},
|
},
|
||||||
@@ -5474,6 +5479,7 @@
|
|||||||
"version": "2.0.1",
|
"version": "2.0.1",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">= 10.0.0"
|
"node": ">= 10.0.0"
|
||||||
}
|
}
|
||||||
@@ -6191,7 +6197,8 @@
|
|||||||
"node_modules/fs-constants": {
|
"node_modules/fs-constants": {
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT",
|
||||||
|
"peer": true
|
||||||
},
|
},
|
||||||
"node_modules/fs-extra": {
|
"node_modules/fs-extra": {
|
||||||
"version": "8.1.0",
|
"version": "8.1.0",
|
||||||
@@ -7408,7 +7415,8 @@
|
|||||||
"node_modules/isarray": {
|
"node_modules/isarray": {
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT",
|
||||||
|
"peer": true
|
||||||
},
|
},
|
||||||
"node_modules/isbinaryfile": {
|
"node_modules/isbinaryfile": {
|
||||||
"version": "5.0.6",
|
"version": "5.0.6",
|
||||||
@@ -7458,7 +7466,6 @@
|
|||||||
"version": "1.21.7",
|
"version": "1.21.7",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"bin": {
|
"bin": {
|
||||||
"jiti": "bin/jiti.js"
|
"jiti": "bin/jiti.js"
|
||||||
}
|
}
|
||||||
@@ -7590,6 +7597,7 @@
|
|||||||
"version": "1.0.1",
|
"version": "1.0.1",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"readable-stream": "^2.0.5"
|
"readable-stream": "^2.0.5"
|
||||||
},
|
},
|
||||||
@@ -7601,6 +7609,7 @@
|
|||||||
"version": "2.3.8",
|
"version": "2.3.8",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"core-util-is": "~1.0.0",
|
"core-util-is": "~1.0.0",
|
||||||
"inherits": "~2.0.3",
|
"inherits": "~2.0.3",
|
||||||
@@ -7614,12 +7623,14 @@
|
|||||||
"node_modules/lazystream/node_modules/safe-buffer": {
|
"node_modules/lazystream/node_modules/safe-buffer": {
|
||||||
"version": "5.1.2",
|
"version": "5.1.2",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT",
|
||||||
|
"peer": true
|
||||||
},
|
},
|
||||||
"node_modules/lazystream/node_modules/string_decoder": {
|
"node_modules/lazystream/node_modules/string_decoder": {
|
||||||
"version": "1.1.1",
|
"version": "1.1.1",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"safe-buffer": "~5.1.0"
|
"safe-buffer": "~5.1.0"
|
||||||
}
|
}
|
||||||
@@ -7684,22 +7695,26 @@
|
|||||||
"node_modules/lodash.defaults": {
|
"node_modules/lodash.defaults": {
|
||||||
"version": "4.2.0",
|
"version": "4.2.0",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT",
|
||||||
|
"peer": true
|
||||||
},
|
},
|
||||||
"node_modules/lodash.difference": {
|
"node_modules/lodash.difference": {
|
||||||
"version": "4.5.0",
|
"version": "4.5.0",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT",
|
||||||
|
"peer": true
|
||||||
},
|
},
|
||||||
"node_modules/lodash.flatten": {
|
"node_modules/lodash.flatten": {
|
||||||
"version": "4.4.0",
|
"version": "4.4.0",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT",
|
||||||
|
"peer": true
|
||||||
},
|
},
|
||||||
"node_modules/lodash.isplainobject": {
|
"node_modules/lodash.isplainobject": {
|
||||||
"version": "4.0.6",
|
"version": "4.0.6",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT",
|
||||||
|
"peer": true
|
||||||
},
|
},
|
||||||
"node_modules/lodash.sortby": {
|
"node_modules/lodash.sortby": {
|
||||||
"version": "4.7.0",
|
"version": "4.7.0",
|
||||||
@@ -7711,7 +7726,8 @@
|
|||||||
"node_modules/lodash.union": {
|
"node_modules/lodash.union": {
|
||||||
"version": "4.6.0",
|
"version": "4.6.0",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT",
|
||||||
|
"peer": true
|
||||||
},
|
},
|
||||||
"node_modules/lowercase-keys": {
|
"node_modules/lowercase-keys": {
|
||||||
"version": "2.0.0",
|
"version": "2.0.0",
|
||||||
@@ -8515,7 +8531,6 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"nanoid": "^3.3.11",
|
"nanoid": "^3.3.11",
|
||||||
"picocolors": "^1.1.1",
|
"picocolors": "^1.1.1",
|
||||||
@@ -8663,7 +8678,8 @@
|
|||||||
"node_modules/process-nextick-args": {
|
"node_modules/process-nextick-args": {
|
||||||
"version": "2.0.1",
|
"version": "2.0.1",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT",
|
||||||
|
"peer": true
|
||||||
},
|
},
|
||||||
"node_modules/process-warning": {
|
"node_modules/process-warning": {
|
||||||
"version": "3.0.0",
|
"version": "3.0.0",
|
||||||
@@ -8912,6 +8928,7 @@
|
|||||||
"version": "3.6.2",
|
"version": "3.6.2",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"inherits": "^2.0.3",
|
"inherits": "^2.0.3",
|
||||||
"string_decoder": "^1.1.1",
|
"string_decoder": "^1.1.1",
|
||||||
@@ -8925,6 +8942,7 @@
|
|||||||
"version": "1.1.3",
|
"version": "1.1.3",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"minimatch": "^5.1.0"
|
"minimatch": "^5.1.0"
|
||||||
}
|
}
|
||||||
@@ -9227,7 +9245,6 @@
|
|||||||
"version": "4.52.5",
|
"version": "4.52.5",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@types/estree": "1.0.8"
|
"@types/estree": "1.0.8"
|
||||||
},
|
},
|
||||||
@@ -9451,7 +9468,6 @@
|
|||||||
"node_modules/seroval": {
|
"node_modules/seroval": {
|
||||||
"version": "1.3.2",
|
"version": "1.3.2",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=10"
|
"node": ">=10"
|
||||||
}
|
}
|
||||||
@@ -9775,7 +9791,6 @@
|
|||||||
"node_modules/solid-js": {
|
"node_modules/solid-js": {
|
||||||
"version": "1.9.10",
|
"version": "1.9.10",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"csstype": "^3.1.0",
|
"csstype": "^3.1.0",
|
||||||
"seroval": "~1.3.0",
|
"seroval": "~1.3.0",
|
||||||
@@ -9916,6 +9931,7 @@
|
|||||||
"version": "1.3.0",
|
"version": "1.3.0",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"safe-buffer": "~5.2.0"
|
"safe-buffer": "~5.2.0"
|
||||||
}
|
}
|
||||||
@@ -10249,6 +10265,7 @@
|
|||||||
"version": "2.2.0",
|
"version": "2.2.0",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"bl": "^4.0.3",
|
"bl": "^4.0.3",
|
||||||
"end-of-stream": "^1.4.1",
|
"end-of-stream": "^1.4.1",
|
||||||
@@ -10441,7 +10458,6 @@
|
|||||||
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=12"
|
"node": ">=12"
|
||||||
},
|
},
|
||||||
@@ -10691,7 +10707,6 @@
|
|||||||
"version": "5.9.3",
|
"version": "5.9.3",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"peer": true,
|
|
||||||
"bin": {
|
"bin": {
|
||||||
"tsc": "bin/tsc",
|
"tsc": "bin/tsc",
|
||||||
"tsserver": "bin/tsserver"
|
"tsserver": "bin/tsserver"
|
||||||
@@ -11039,7 +11054,6 @@
|
|||||||
"version": "5.4.21",
|
"version": "5.4.21",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"esbuild": "^0.21.3",
|
"esbuild": "^0.21.3",
|
||||||
"postcss": "^8.4.43",
|
"postcss": "^8.4.43",
|
||||||
@@ -11524,7 +11538,6 @@
|
|||||||
"integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==",
|
"integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"fast-deep-equal": "^3.1.3",
|
"fast-deep-equal": "^3.1.3",
|
||||||
"fast-uri": "^3.0.1",
|
"fast-uri": "^3.0.1",
|
||||||
@@ -11719,7 +11732,6 @@
|
|||||||
"integrity": "sha512-fS6iqSPZDs3dr/y7Od6y5nha8dW1YnbgtsyotCVvoFGKbERG++CVRFv1meyGDE1SNItQA8BrnCw7ScdAhRJ3XQ==",
|
"integrity": "sha512-fS6iqSPZDs3dr/y7Od6y5nha8dW1YnbgtsyotCVvoFGKbERG++CVRFv1meyGDE1SNItQA8BrnCw7ScdAhRJ3XQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"bin": {
|
"bin": {
|
||||||
"rollup": "dist/bin/rollup"
|
"rollup": "dist/bin/rollup"
|
||||||
},
|
},
|
||||||
@@ -12008,6 +12020,7 @@
|
|||||||
"version": "4.1.1",
|
"version": "4.1.1",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"archiver-utils": "^3.0.4",
|
"archiver-utils": "^3.0.4",
|
||||||
"compress-commons": "^4.1.2",
|
"compress-commons": "^4.1.2",
|
||||||
@@ -12021,6 +12034,7 @@
|
|||||||
"version": "3.0.4",
|
"version": "3.0.4",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"glob": "^7.2.3",
|
"glob": "^7.2.3",
|
||||||
"graceful-fs": "^4.2.0",
|
"graceful-fs": "^4.2.0",
|
||||||
@@ -12040,7 +12054,6 @@
|
|||||||
"node_modules/zod": {
|
"node_modules/zod": {
|
||||||
"version": "3.25.76",
|
"version": "3.25.76",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"funding": {
|
"funding": {
|
||||||
"url": "https://github.com/sponsors/colinhacks"
|
"url": "https://github.com/sponsors/colinhacks"
|
||||||
}
|
}
|
||||||
@@ -12055,7 +12068,7 @@
|
|||||||
},
|
},
|
||||||
"packages/electron-app": {
|
"packages/electron-app": {
|
||||||
"name": "@neuralnomads/codenomad-electron-app",
|
"name": "@neuralnomads/codenomad-electron-app",
|
||||||
"version": "0.13.1",
|
"version": "0.13.3",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@codenomad/ui": "file:../ui",
|
"@codenomad/ui": "file:../ui",
|
||||||
@@ -12092,7 +12105,7 @@
|
|||||||
},
|
},
|
||||||
"packages/server": {
|
"packages/server": {
|
||||||
"name": "@neuralnomads/codenomad",
|
"name": "@neuralnomads/codenomad",
|
||||||
"version": "0.13.1",
|
"version": "0.13.3",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@fastify/cors": "^8.5.0",
|
"@fastify/cors": "^8.5.0",
|
||||||
@@ -12134,7 +12147,7 @@
|
|||||||
},
|
},
|
||||||
"packages/tauri-app": {
|
"packages/tauri-app": {
|
||||||
"name": "@codenomad/tauri-app",
|
"name": "@codenomad/tauri-app",
|
||||||
"version": "0.13.1",
|
"version": "0.13.3",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@tauri-apps/cli": "^2.9.4"
|
"@tauri-apps/cli": "^2.9.4"
|
||||||
@@ -12142,7 +12155,7 @@
|
|||||||
},
|
},
|
||||||
"packages/ui": {
|
"packages/ui": {
|
||||||
"name": "@codenomad/ui",
|
"name": "@codenomad/ui",
|
||||||
"version": "0.13.1",
|
"version": "0.13.3",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@git-diff-view/solid": "^0.0.8",
|
"@git-diff-view/solid": "^0.0.8",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "codenomad-workspace",
|
"name": "codenomad-workspace",
|
||||||
"version": "0.13.1",
|
"version": "0.13.3",
|
||||||
"private": true,
|
"private": true,
|
||||||
"description": "CodeNomad monorepo workspace",
|
"description": "CodeNomad monorepo workspace",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
@@ -22,7 +22,7 @@
|
|||||||
"build:mac-x64": "npm run build:mac-x64 --workspace @neuralnomads/codenomad-electron-app",
|
"build:mac-x64": "npm run build:mac-x64 --workspace @neuralnomads/codenomad-electron-app",
|
||||||
"build:binaries": "npm run build:binaries --workspace @neuralnomads/codenomad-electron-app",
|
"build:binaries": "npm run build:binaries --workspace @neuralnomads/codenomad-electron-app",
|
||||||
"typecheck": "npm run typecheck --workspace @codenomad/ui && npm run typecheck --workspace @neuralnomads/codenomad-electron-app",
|
"typecheck": "npm run typecheck --workspace @codenomad/ui && npm run typecheck --workspace @neuralnomads/codenomad-electron-app",
|
||||||
"bumpVersion": "npm version --workspaces --include-workspace-root --no-git-tag-version && npm run sync:version --workspace @codenomad/tauri-app"
|
"bumpVersion": "node ./scripts/bump-version.js"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"7zip-bin": "^5.2.0",
|
"7zip-bin": "^5.2.0",
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
{
|
{
|
||||||
"minServerVersion": "0.13.1",
|
"minServerVersion": "0.13.3",
|
||||||
"latestServerUrl": "https://github.com/NeuralNomadsAI/CodeNomad/releases/latest"
|
"latestServerUrl": "https://github.com/NeuralNomadsAI/CodeNomad/releases/latest"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,23 @@ export interface Env {
|
|||||||
|
|
||||||
export default {
|
export default {
|
||||||
async fetch(request: Request, env: Env): Promise<Response> {
|
async fetch(request: Request, env: Env): Promise<Response> {
|
||||||
|
const url = new URL(request.url)
|
||||||
|
|
||||||
|
if (url.pathname === "/version.json") {
|
||||||
|
const response = await env.ASSETS.fetch(request)
|
||||||
|
|
||||||
|
const newHeaders = new Headers(response.headers)
|
||||||
|
newHeaders.set("Cache-Control", "no-store, no-cache, must-revalidate, proxy-revalidate")
|
||||||
|
newHeaders.set("Pragma", "no-cache")
|
||||||
|
newHeaders.set("Expires", "0")
|
||||||
|
|
||||||
|
return new Response(response.body, {
|
||||||
|
status: response.status,
|
||||||
|
statusText: response.statusText,
|
||||||
|
headers: newHeaders,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
return env.ASSETS.fetch(request)
|
return env.ASSETS.fetch(request)
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -117,6 +117,28 @@ export function setupCliIPC(mainWindow: BrowserWindow, cliManager: CliProcessMan
|
|||||||
async (): Promise<{ granted: boolean }> => ({ granted: await requestMicrophoneAccess() }),
|
async (): Promise<{ granted: boolean }> => ({ granted: await requestMicrophoneAccess() }),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
ipcMain.handle(
|
||||||
|
"remote:openWindow",
|
||||||
|
async (
|
||||||
|
_event,
|
||||||
|
payload: { id: string; name: string; baseUrl: string; skipTlsVerify: boolean },
|
||||||
|
): Promise<{ ok: boolean }> => {
|
||||||
|
const opener = (mainWindow as BrowserWindow & {
|
||||||
|
__codenomadOpenRemoteWindow?: (payload: {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
baseUrl: string
|
||||||
|
skipTlsVerify: boolean
|
||||||
|
}) => Promise<void>
|
||||||
|
}).__codenomadOpenRemoteWindow
|
||||||
|
if (!opener) {
|
||||||
|
throw new Error("Remote window opening is not available")
|
||||||
|
}
|
||||||
|
await opener(payload)
|
||||||
|
return { ok: true }
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
ipcMain.handle(
|
ipcMain.handle(
|
||||||
"notifications:show",
|
"notifications:show",
|
||||||
async (_event, payload: { title?: unknown; body?: unknown }): Promise<{ ok: boolean; reason?: string }> => {
|
async (_event, payload: { title?: unknown; body?: unknown }): Promise<{ ok: boolean; reason?: string }> => {
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { app, BrowserView, BrowserWindow, nativeImage, session, shell } from "electron"
|
import { app, BrowserView, BrowserWindow, nativeImage, session, shell } from "electron"
|
||||||
import http from "node:http"
|
import http from "node:http"
|
||||||
import https from "node:https"
|
import https from "node:https"
|
||||||
import { existsSync } from "fs"
|
import { existsSync, mkdirSync } from "fs"
|
||||||
import { dirname, join } from "path"
|
import { dirname, join } from "path"
|
||||||
import { fileURLToPath } from "url"
|
import { fileURLToPath } from "url"
|
||||||
import { createApplicationMenu } from "./menu"
|
import { createApplicationMenu } from "./menu"
|
||||||
@@ -14,6 +14,31 @@ const mainDirname = dirname(mainFilename)
|
|||||||
|
|
||||||
const isMac = process.platform === "darwin"
|
const isMac = process.platform === "darwin"
|
||||||
|
|
||||||
|
function configureDevStoragePaths() {
|
||||||
|
if (app.isPackaged) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const appName = "CodeNomad"
|
||||||
|
|
||||||
|
try {
|
||||||
|
app.setName(appName)
|
||||||
|
|
||||||
|
const userDataPath = join(app.getPath("appData"), appName)
|
||||||
|
const sessionDataPath = join(userDataPath, "session-data")
|
||||||
|
|
||||||
|
mkdirSync(userDataPath, { recursive: true })
|
||||||
|
mkdirSync(sessionDataPath, { recursive: true })
|
||||||
|
|
||||||
|
app.setPath("userData", userDataPath)
|
||||||
|
app.setPath("sessionData", sessionDataPath)
|
||||||
|
} catch (error) {
|
||||||
|
console.warn("[cli] failed to configure dev storage paths", error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
configureDevStoragePaths()
|
||||||
|
|
||||||
const cliManager = new CliProcessManager()
|
const cliManager = new CliProcessManager()
|
||||||
let mainWindow: BrowserWindow | null = null
|
let mainWindow: BrowserWindow | null = null
|
||||||
let currentCliUrl: string | null = null
|
let currentCliUrl: string | null = null
|
||||||
@@ -21,6 +46,8 @@ let pendingCliUrl: string | null = null
|
|||||||
let pendingBootstrapToken: string | null = null
|
let pendingBootstrapToken: string | null = null
|
||||||
let showingLoadingScreen = false
|
let showingLoadingScreen = false
|
||||||
let preloadingView: BrowserView | null = null
|
let preloadingView: BrowserView | null = null
|
||||||
|
const remoteWindowOrigins = new Map<number, Set<string>>()
|
||||||
|
const insecureWindowOrigins = new Map<number, Set<string>>()
|
||||||
|
|
||||||
if (isMac) {
|
if (isMac) {
|
||||||
app.commandLine.appendSwitch("disable-spell-checking")
|
app.commandLine.appendSwitch("disable-spell-checking")
|
||||||
@@ -93,8 +120,13 @@ function loadLoadingScreen(window: BrowserWindow) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
function getAllowedRendererOrigins(): string[] {
|
function getAllowedRendererOrigins(window?: BrowserWindow | null): string[] {
|
||||||
const origins = new Set<string>()
|
const origins = new Set<string>()
|
||||||
|
if (window) {
|
||||||
|
for (const origin of remoteWindowOrigins.get(window.id) ?? []) {
|
||||||
|
origins.add(origin)
|
||||||
|
}
|
||||||
|
}
|
||||||
const rendererCandidates = [currentCliUrl, process.env.VITE_DEV_SERVER_URL, process.env.ELECTRON_RENDERER_URL]
|
const rendererCandidates = [currentCliUrl, process.env.VITE_DEV_SERVER_URL, process.env.ELECTRON_RENDERER_URL]
|
||||||
for (const candidate of rendererCandidates) {
|
for (const candidate of rendererCandidates) {
|
||||||
if (!candidate) {
|
if (!candidate) {
|
||||||
@@ -109,13 +141,13 @@ function getAllowedRendererOrigins(): string[] {
|
|||||||
return Array.from(origins)
|
return Array.from(origins)
|
||||||
}
|
}
|
||||||
|
|
||||||
function shouldOpenExternally(url: string): boolean {
|
function shouldOpenExternally(url: string, window?: BrowserWindow | null): boolean {
|
||||||
try {
|
try {
|
||||||
const parsed = new URL(url)
|
const parsed = new URL(url)
|
||||||
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
|
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
const allowedOrigins = getAllowedRendererOrigins()
|
const allowedOrigins = getAllowedRendererOrigins(window)
|
||||||
return !allowedOrigins.includes(parsed.origin)
|
return !allowedOrigins.includes(parsed.origin)
|
||||||
} catch {
|
} catch {
|
||||||
return false
|
return false
|
||||||
@@ -128,7 +160,7 @@ function setupNavigationGuards(window: BrowserWindow) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
window.webContents.setWindowOpenHandler(({ url }) => {
|
window.webContents.setWindowOpenHandler(({ url }) => {
|
||||||
if (shouldOpenExternally(url)) {
|
if (shouldOpenExternally(url, window)) {
|
||||||
handleExternal(url)
|
handleExternal(url)
|
||||||
return { action: "deny" }
|
return { action: "deny" }
|
||||||
}
|
}
|
||||||
@@ -136,13 +168,54 @@ function setupNavigationGuards(window: BrowserWindow) {
|
|||||||
})
|
})
|
||||||
|
|
||||||
window.webContents.on("will-navigate", (event, url) => {
|
window.webContents.on("will-navigate", (event, url) => {
|
||||||
if (shouldOpenExternally(url)) {
|
if (shouldOpenExternally(url, window)) {
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
handleExternal(url)
|
handleExternal(url)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function setWindowAllowedOrigin(window: BrowserWindow, url: string) {
|
||||||
|
try {
|
||||||
|
const origin = new URL(url).origin
|
||||||
|
remoteWindowOrigins.set(window.id, new Set([origin]))
|
||||||
|
} catch (error) {
|
||||||
|
console.warn("[cli] failed to store allowed origin", url, error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearWindowAllowedOrigin(window: BrowserWindow) {
|
||||||
|
remoteWindowOrigins.delete(window.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
function addWindowInsecureOrigin(window: BrowserWindow, url: string) {
|
||||||
|
try {
|
||||||
|
const origin = new URL(url).origin
|
||||||
|
insecureWindowOrigins.set(window.id, new Set([origin]))
|
||||||
|
} catch (error) {
|
||||||
|
console.warn("[cli] failed to store insecure origin", url, error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearWindowInsecureOrigin(window: BrowserWindow) {
|
||||||
|
insecureWindowOrigins.delete(window.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
function isInsecureOriginAllowed(url: string) {
|
||||||
|
try {
|
||||||
|
const targetOrigin = new URL(url).origin
|
||||||
|
for (const origins of insecureWindowOrigins.values()) {
|
||||||
|
if (origins.has(targetOrigin)) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
let cachedPreloadPath: string | null = null
|
let cachedPreloadPath: string | null = null
|
||||||
function getPreloadPath() {
|
function getPreloadPath() {
|
||||||
if (cachedPreloadPath && existsSync(cachedPreloadPath)) {
|
if (cachedPreloadPath && existsSync(cachedPreloadPath)) {
|
||||||
@@ -207,25 +280,30 @@ function createWindow() {
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
setupNavigationGuards(mainWindow)
|
const window = mainWindow
|
||||||
|
|
||||||
|
setupNavigationGuards(window)
|
||||||
|
|
||||||
if (isMac) {
|
if (isMac) {
|
||||||
mainWindow.webContents.session.setSpellCheckerEnabled(false)
|
window.webContents.session.setSpellCheckerEnabled(false)
|
||||||
}
|
}
|
||||||
|
|
||||||
showingLoadingScreen = true
|
showingLoadingScreen = true
|
||||||
currentCliUrl = null
|
currentCliUrl = null
|
||||||
loadLoadingScreen(mainWindow)
|
clearWindowAllowedOrigin(window)
|
||||||
|
loadLoadingScreen(window)
|
||||||
|
|
||||||
if (process.env.NODE_ENV === "development") {
|
if (process.env.NODE_ENV === "development") {
|
||||||
mainWindow.webContents.openDevTools({ mode: "detach" })
|
window.webContents.openDevTools({ mode: "detach" })
|
||||||
}
|
}
|
||||||
|
|
||||||
createApplicationMenu(mainWindow)
|
createApplicationMenu(window)
|
||||||
setupCliIPC(mainWindow, cliManager)
|
setupCliIPC(window, cliManager)
|
||||||
|
|
||||||
mainWindow.on("closed", () => {
|
window.on("closed", () => {
|
||||||
destroyPreloadingView()
|
destroyPreloadingView()
|
||||||
|
clearWindowAllowedOrigin(window)
|
||||||
|
clearWindowInsecureOrigin(window)
|
||||||
mainWindow = null
|
mainWindow = null
|
||||||
currentCliUrl = null
|
currentCliUrl = null
|
||||||
pendingCliUrl = null
|
pendingCliUrl = null
|
||||||
@@ -322,13 +400,68 @@ function finalizeCliSwap(url: string) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const window = mainWindow
|
||||||
showingLoadingScreen = false
|
showingLoadingScreen = false
|
||||||
currentCliUrl = url
|
currentCliUrl = url
|
||||||
|
setWindowAllowedOrigin(window, url)
|
||||||
pendingCliUrl = null
|
pendingCliUrl = null
|
||||||
mainWindow.loadURL(url).catch((error) => console.error("[cli] failed to load CLI view:", error))
|
window.loadURL(url).catch((error) => console.error("[cli] failed to load CLI view:", error))
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildRemoteWindowTitle(name: string, baseUrl: string) {
|
||||||
|
try {
|
||||||
|
const parsed = new URL(baseUrl)
|
||||||
|
return `${name} - ${parsed.host}`
|
||||||
|
} catch {
|
||||||
|
return `${name} - ${baseUrl}`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildRemoteErrorHtml(name: string, baseUrl: string, message: string) {
|
||||||
|
const escapedName = name.replace(/[&<>"]/g, (char) => ({ "&": "&", "<": "<", ">": ">", '"': """ }[char] ?? char))
|
||||||
|
const escapedUrl = baseUrl.replace(/[&<>"]/g, (char) => ({ "&": "&", "<": "<", ">": ">", '"': """ }[char] ?? char))
|
||||||
|
const escapedMessage = message.replace(/[&<>"]/g, (char) => ({ "&": "&", "<": "<", ">": ">", '"': """ }[char] ?? char))
|
||||||
|
return `<!doctype html><html><head><meta charset="utf-8" /><title>${escapedName}</title><style>body{margin:0;background:#111827;color:#f9fafb;font-family:Inter,system-ui,sans-serif;display:flex;align-items:center;justify-content:center;min-height:100vh;padding:24px}main{max-width:560px;width:100%;background:rgba(17,24,39,.88);border:1px solid rgba(255,255,255,.08);border-radius:20px;padding:28px;box-shadow:0 25px 60px rgba(0,0,0,.45)}h1{margin:0 0 10px;font-size:1.5rem}p{margin:0 0 10px;color:#cbd5e1;line-height:1.5}code{display:block;margin-top:16px;padding:12px 14px;border-radius:12px;background:#0f172a;color:#bfdbfe;overflow:auto}</style></head><body><main><h1>${escapedName}</h1><p>Could not connect to the remote server.</p><p>${escapedMessage}</p><code>${escapedUrl}</code></main></body></html>`
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openRemoteWindow(payload: { id: string; name: string; baseUrl: string; skipTlsVerify: boolean }) {
|
||||||
|
const targetUrl = new URL(payload.baseUrl)
|
||||||
|
const title = buildRemoteWindowTitle(payload.name, payload.baseUrl)
|
||||||
|
const window = new BrowserWindow({
|
||||||
|
width: 1400,
|
||||||
|
height: 900,
|
||||||
|
minWidth: 800,
|
||||||
|
minHeight: 600,
|
||||||
|
backgroundColor: "#1a1a1a",
|
||||||
|
icon: getIconPath(),
|
||||||
|
title,
|
||||||
|
webPreferences: {
|
||||||
|
preload: getPreloadPath(),
|
||||||
|
contextIsolation: true,
|
||||||
|
nodeIntegration: false,
|
||||||
|
spellcheck: !isMac,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
setWindowAllowedOrigin(window, targetUrl.toString())
|
||||||
|
if (payload.skipTlsVerify) {
|
||||||
|
addWindowInsecureOrigin(window, targetUrl.toString())
|
||||||
|
}
|
||||||
|
|
||||||
|
setupNavigationGuards(window)
|
||||||
|
window.on("closed", () => {
|
||||||
|
clearWindowAllowedOrigin(window)
|
||||||
|
clearWindowInsecureOrigin(window)
|
||||||
|
})
|
||||||
|
|
||||||
|
try {
|
||||||
|
await window.loadURL(targetUrl.toString())
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : String(error)
|
||||||
|
await window.loadURL(`data:text/html;charset=utf-8,${encodeURIComponent(buildRemoteErrorHtml(payload.name, payload.baseUrl, message))}`)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const SESSION_COOKIE_NAME = "codenomad_session"
|
|
||||||
let bootstrapExchangeInFlight = false
|
let bootstrapExchangeInFlight = false
|
||||||
|
|
||||||
function extractCookieValue(setCookieHeader: string | string[] | undefined, name: string): string | null {
|
function extractCookieValue(setCookieHeader: string | string[] | undefined, name: string): string | null {
|
||||||
@@ -351,6 +484,7 @@ function extractCookieValue(setCookieHeader: string | string[] | undefined, name
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function exchangeBootstrapToken(baseUrl: string, token: string): Promise<boolean> {
|
async function exchangeBootstrapToken(baseUrl: string, token: string): Promise<boolean> {
|
||||||
|
const sessionCookieName = cliManager.getAuthCookieName()
|
||||||
const target = new URL("/api/auth/token", baseUrl)
|
const target = new URL("/api/auth/token", baseUrl)
|
||||||
const body = JSON.stringify({ token })
|
const body = JSON.stringify({ token })
|
||||||
|
|
||||||
@@ -381,14 +515,14 @@ async function exchangeBootstrapToken(baseUrl: string, token: string): Promise<b
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
const sessionId = extractCookieValue(result.setCookie, SESSION_COOKIE_NAME)
|
const sessionId = extractCookieValue(result.setCookie, sessionCookieName)
|
||||||
if (!sessionId) {
|
if (!sessionId) {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
await session.defaultSession.cookies.set({
|
await session.defaultSession.cookies.set({
|
||||||
url: baseUrl,
|
url: baseUrl,
|
||||||
name: SESSION_COOKIE_NAME,
|
name: sessionCookieName,
|
||||||
value: sessionId,
|
value: sessionId,
|
||||||
httpOnly: true,
|
httpOnly: true,
|
||||||
path: "/",
|
path: "/",
|
||||||
@@ -504,6 +638,17 @@ app.whenReady().then(() => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
createWindow()
|
createWindow()
|
||||||
|
;(mainWindow as BrowserWindow & { __codenomadOpenRemoteWindow?: typeof openRemoteWindow }).__codenomadOpenRemoteWindow = openRemoteWindow
|
||||||
|
|
||||||
|
app.on("certificate-error", (event, _webContents, url, error, _certificate, callback) => {
|
||||||
|
if (isInsecureOriginAllowed(url)) {
|
||||||
|
event.preventDefault()
|
||||||
|
console.warn("[cli] allowing insecure remote certificate for", url, error)
|
||||||
|
callback(true)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
callback(false)
|
||||||
|
})
|
||||||
|
|
||||||
app.on("activate", () => {
|
app.on("activate", () => {
|
||||||
if (BrowserWindow.getAllWindows().length === 0) {
|
if (BrowserWindow.getAllWindows().length === 0) {
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ const mainFilename = fileURLToPath(import.meta.url)
|
|||||||
const mainDirname = path.dirname(mainFilename)
|
const mainDirname = path.dirname(mainFilename)
|
||||||
|
|
||||||
const BOOTSTRAP_TOKEN_PREFIX = "CODENOMAD_BOOTSTRAP_TOKEN:"
|
const BOOTSTRAP_TOKEN_PREFIX = "CODENOMAD_BOOTSTRAP_TOKEN:"
|
||||||
|
const SESSION_COOKIE_NAME_PREFIX = "codenomad_session"
|
||||||
|
|
||||||
type CliState = "starting" | "ready" | "error" | "stopped"
|
type CliState = "starting" | "ready" | "error" | "stopped"
|
||||||
type ListeningMode = "local" | "all"
|
type ListeningMode = "local" | "all"
|
||||||
@@ -129,6 +130,7 @@ export class CliProcessManager extends EventEmitter {
|
|||||||
private stdoutBuffer = ""
|
private stdoutBuffer = ""
|
||||||
private stderrBuffer = ""
|
private stderrBuffer = ""
|
||||||
private bootstrapToken: string | null = null
|
private bootstrapToken: string | null = null
|
||||||
|
private authCookieName = `${SESSION_COOKIE_NAME_PREFIX}_${process.pid}_${Date.now()}`
|
||||||
private requestedStop = false
|
private requestedStop = false
|
||||||
|
|
||||||
async start(options: StartOptions): Promise<CliStatus> {
|
async start(options: StartOptions): Promise<CliStatus> {
|
||||||
@@ -139,6 +141,7 @@ export class CliProcessManager extends EventEmitter {
|
|||||||
this.stdoutBuffer = ""
|
this.stdoutBuffer = ""
|
||||||
this.stderrBuffer = ""
|
this.stderrBuffer = ""
|
||||||
this.bootstrapToken = null
|
this.bootstrapToken = null
|
||||||
|
this.authCookieName = `${SESSION_COOKIE_NAME_PREFIX}_${process.pid}_${Date.now()}`
|
||||||
this.requestedStop = false
|
this.requestedStop = false
|
||||||
this.updateStatus({ state: "starting", port: undefined, pid: undefined, url: undefined, error: undefined })
|
this.updateStatus({ state: "starting", port: undefined, pid: undefined, url: undefined, error: undefined })
|
||||||
|
|
||||||
@@ -436,6 +439,10 @@ export class CliProcessManager extends EventEmitter {
|
|||||||
return { ...this.status }
|
return { ...this.status }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
getAuthCookieName(): string {
|
||||||
|
return this.authCookieName
|
||||||
|
}
|
||||||
|
|
||||||
private resolveListeningMode(): ListeningMode {
|
private resolveListeningMode(): ListeningMode {
|
||||||
return readListeningModeFromConfig()
|
return readListeningModeFromConfig()
|
||||||
}
|
}
|
||||||
@@ -532,7 +539,7 @@ export class CliProcessManager extends EventEmitter {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private buildCliArgs(options: StartOptions, host: string): string[] {
|
private buildCliArgs(options: StartOptions, host: string): string[] {
|
||||||
const args = ["serve", "--host", host, "--generate-token"]
|
const args = ["serve", "--host", host, "--generate-token", "--auth-cookie-name", this.authCookieName]
|
||||||
|
|
||||||
if (options.dev) {
|
if (options.dev) {
|
||||||
// Dev: run plain HTTP + Vite dev server proxy.
|
// Dev: run plain HTTP + Vite dev server proxy.
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ const electronAPI = {
|
|||||||
requestMicrophoneAccess: () => ipcRenderer.invoke("media:requestMicrophoneAccess"),
|
requestMicrophoneAccess: () => ipcRenderer.invoke("media:requestMicrophoneAccess"),
|
||||||
setWakeLock: (enabled) => ipcRenderer.invoke("power:setWakeLock", Boolean(enabled)),
|
setWakeLock: (enabled) => ipcRenderer.invoke("power:setWakeLock", Boolean(enabled)),
|
||||||
showNotification: (payload) => ipcRenderer.invoke("notifications:show", payload),
|
showNotification: (payload) => ipcRenderer.invoke("notifications:show", payload),
|
||||||
|
openRemoteWindow: (payload) => ipcRenderer.invoke("remote:openWindow", payload),
|
||||||
}
|
}
|
||||||
|
|
||||||
contextBridge.exposeInMainWorld("electronAPI", electronAPI)
|
contextBridge.exposeInMainWorld("electronAPI", electronAPI)
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@neuralnomads/codenomad-electron-app",
|
"name": "@neuralnomads/codenomad-electron-app",
|
||||||
"version": "0.13.1",
|
"version": "0.13.3",
|
||||||
"description": "CodeNomad - AI coding assistant",
|
"description": "CodeNomad - AI coding assistant",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"author": {
|
"author": {
|
||||||
|
|||||||
@@ -4,6 +4,6 @@
|
|||||||
"private": true,
|
"private": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@opencode-ai/plugin": "1.3.2"
|
"@opencode-ai/plugin": "1.3.7"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
4
packages/server/package-lock.json
generated
4
packages/server/package-lock.json
generated
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "@neuralnomads/codenomad",
|
"name": "@neuralnomads/codenomad",
|
||||||
"version": "0.13.1",
|
"version": "0.13.3",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "@neuralnomads/codenomad",
|
"name": "@neuralnomads/codenomad",
|
||||||
"version": "0.13.1",
|
"version": "0.13.3",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@fastify/cors": "^8.5.0",
|
"@fastify/cors": "^8.5.0",
|
||||||
"@fastify/reply-from": "^9.8.0",
|
"@fastify/reply-from": "^9.8.0",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@neuralnomads/codenomad",
|
"name": "@neuralnomads/codenomad",
|
||||||
"version": "0.13.1",
|
"version": "0.13.3",
|
||||||
"description": "CodeNomad Server",
|
"description": "CodeNomad Server",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"author": {
|
"author": {
|
||||||
|
|||||||
@@ -170,6 +170,24 @@ export interface InstanceStreamEvent {
|
|||||||
[key: string]: unknown
|
[key: string]: unknown
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type SideCarKind = "port"
|
||||||
|
|
||||||
|
export type SideCarPrefixMode = "strip" | "preserve"
|
||||||
|
|
||||||
|
export type SideCarStatus = "running" | "stopped"
|
||||||
|
|
||||||
|
export interface SideCar {
|
||||||
|
id: string
|
||||||
|
kind: SideCarKind
|
||||||
|
name: string
|
||||||
|
port: number
|
||||||
|
insecure: boolean
|
||||||
|
prefixMode: SideCarPrefixMode
|
||||||
|
status: SideCarStatus
|
||||||
|
createdAt: string
|
||||||
|
updatedAt: string
|
||||||
|
}
|
||||||
|
|
||||||
export interface BinaryRecord {
|
export interface BinaryRecord {
|
||||||
id: string
|
id: string
|
||||||
path: string
|
path: string
|
||||||
@@ -244,12 +262,40 @@ export interface VoiceModeStateResponse {
|
|||||||
enabled: boolean
|
enabled: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface RemoteServerProfile {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
baseUrl: string
|
||||||
|
skipTlsVerify: boolean
|
||||||
|
createdAt: string
|
||||||
|
updatedAt: string
|
||||||
|
lastConnectedAt?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RemoteServerProbeRequest {
|
||||||
|
baseUrl: string
|
||||||
|
skipTlsVerify?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RemoteServerProbeResponse {
|
||||||
|
ok: boolean
|
||||||
|
reachable: boolean
|
||||||
|
normalizedUrl: string
|
||||||
|
skipTlsVerify: boolean
|
||||||
|
requiresAuth: boolean
|
||||||
|
authenticated: boolean
|
||||||
|
error?: string
|
||||||
|
errorCode?: string
|
||||||
|
}
|
||||||
|
|
||||||
export type WorkspaceEventType =
|
export type WorkspaceEventType =
|
||||||
| "workspace.created"
|
| "workspace.created"
|
||||||
| "workspace.started"
|
| "workspace.started"
|
||||||
| "workspace.error"
|
| "workspace.error"
|
||||||
| "workspace.stopped"
|
| "workspace.stopped"
|
||||||
| "workspace.log"
|
| "workspace.log"
|
||||||
|
| "sidecar.updated"
|
||||||
|
| "sidecar.removed"
|
||||||
| "storage.configChanged"
|
| "storage.configChanged"
|
||||||
| "storage.stateChanged"
|
| "storage.stateChanged"
|
||||||
| "instance.dataChanged"
|
| "instance.dataChanged"
|
||||||
@@ -262,6 +308,8 @@ export type WorkspaceEventPayload =
|
|||||||
| { type: "workspace.error"; workspace: WorkspaceDescriptor }
|
| { type: "workspace.error"; workspace: WorkspaceDescriptor }
|
||||||
| { type: "workspace.stopped"; workspaceId: string }
|
| { type: "workspace.stopped"; workspaceId: string }
|
||||||
| { type: "workspace.log"; entry: WorkspaceLogEntry }
|
| { type: "workspace.log"; entry: WorkspaceLogEntry }
|
||||||
|
| { type: "sidecar.updated"; sidecar: SideCar }
|
||||||
|
| { type: "sidecar.removed"; sidecarId: string }
|
||||||
| { type: "storage.configChanged"; owner: SettingsOwner; value: SettingsBucket }
|
| { type: "storage.configChanged"; owner: SettingsOwner; value: SettingsBucket }
|
||||||
| { type: "storage.stateChanged"; owner: SettingsOwner; value: SettingsBucket }
|
| { type: "storage.stateChanged"; owner: SettingsOwner; value: SettingsBucket }
|
||||||
| { type: "instance.dataChanged"; instanceId: string; data: InstanceData }
|
| { type: "instance.dataChanged"; instanceId: string; data: InstanceData }
|
||||||
|
|||||||
@@ -16,16 +16,18 @@ export interface AuthManagerInit {
|
|||||||
password?: string
|
password?: string
|
||||||
generateToken: boolean
|
generateToken: boolean
|
||||||
dangerouslySkipAuth?: boolean
|
dangerouslySkipAuth?: boolean
|
||||||
|
cookieName?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export class AuthManager {
|
export class AuthManager {
|
||||||
private readonly authStore: AuthStore | null
|
private readonly authStore: AuthStore | null
|
||||||
private readonly tokenManager: TokenManager | null
|
private readonly tokenManager: TokenManager | null
|
||||||
private readonly sessionManager = new SessionManager()
|
private readonly sessionManager = new SessionManager()
|
||||||
private readonly cookieName = DEFAULT_AUTH_COOKIE_NAME
|
private readonly cookieName: string
|
||||||
private readonly authEnabled: boolean
|
private readonly authEnabled: boolean
|
||||||
|
|
||||||
constructor(private readonly init: AuthManagerInit, private readonly logger: Logger) {
|
constructor(private readonly init: AuthManagerInit, private readonly logger: Logger) {
|
||||||
|
this.cookieName = sanitizeCookieName(init.cookieName)
|
||||||
this.authEnabled = !Boolean(init.dangerouslySkipAuth)
|
this.authEnabled = !Boolean(init.dangerouslySkipAuth)
|
||||||
|
|
||||||
if (!this.authEnabled) {
|
if (!this.authEnabled) {
|
||||||
@@ -102,13 +104,18 @@ export class AuthManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
getSessionFromRequest(request: FastifyRequest): { username: string; sessionId: string } | null {
|
getSessionFromRequest(request: FastifyRequest): { username: string; sessionId: string } | null {
|
||||||
|
return this.getSessionFromHeaders(request.headers)
|
||||||
|
}
|
||||||
|
|
||||||
|
getSessionFromHeaders(headers: { cookie?: string | string[] | undefined }): { username: string; sessionId: string } | null {
|
||||||
if (!this.authEnabled) {
|
if (!this.authEnabled) {
|
||||||
// When auth is disabled, treat all requests as authenticated.
|
// When auth is disabled, treat all requests as authenticated.
|
||||||
// We still return a stable username so callers can display it.
|
// We still return a stable username so callers can display it.
|
||||||
return { username: this.init.username, sessionId: "auth-disabled" }
|
return { username: this.init.username, sessionId: "auth-disabled" }
|
||||||
}
|
}
|
||||||
|
|
||||||
const cookies = parseCookies(request.headers.cookie)
|
const cookieHeader = Array.isArray(headers.cookie) ? headers.cookie.join("; ") : headers.cookie
|
||||||
|
const cookies = parseCookies(cookieHeader)
|
||||||
const sessionId = cookies[this.cookieName]
|
const sessionId = cookies[this.cookieName]
|
||||||
const session = this.sessionManager.getSession(sessionId)
|
const session = this.sessionManager.getSession(sessionId)
|
||||||
if (!session) return null
|
if (!session) return null
|
||||||
@@ -139,6 +146,16 @@ export class AuthManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function sanitizeCookieName(value: string | undefined): string {
|
||||||
|
const trimmed = value?.trim()
|
||||||
|
if (!trimmed) {
|
||||||
|
return DEFAULT_AUTH_COOKIE_NAME
|
||||||
|
}
|
||||||
|
|
||||||
|
const sanitized = trimmed.replace(/[^A-Za-z0-9_-]/g, "_")
|
||||||
|
return sanitized.length > 0 ? sanitized : DEFAULT_AUTH_COOKIE_NAME
|
||||||
|
}
|
||||||
|
|
||||||
function resolveAuthFilePath(configPath: string) {
|
function resolveAuthFilePath(configPath: string) {
|
||||||
const resolvedConfigPath = resolvePath(configPath)
|
const resolvedConfigPath = resolvePath(configPath)
|
||||||
return path.join(path.dirname(resolvedConfigPath), "auth.json")
|
return path.join(path.dirname(resolvedConfigPath), "auth.json")
|
||||||
|
|||||||
128
packages/server/src/clients/connection-manager.ts
Normal file
128
packages/server/src/clients/connection-manager.ts
Normal file
@@ -0,0 +1,128 @@
|
|||||||
|
import type { Logger } from "../logger"
|
||||||
|
|
||||||
|
const STALE_CONNECTION_TIMEOUT_MS = 45000
|
||||||
|
const STALE_SWEEP_INTERVAL_MS = 5000
|
||||||
|
|
||||||
|
export interface ClientConnectionRef {
|
||||||
|
clientId: string
|
||||||
|
connectionId: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ClientConnectionRecord extends ClientConnectionRef {
|
||||||
|
key: string
|
||||||
|
connectedAt: number
|
||||||
|
lastSeenAt: number
|
||||||
|
}
|
||||||
|
|
||||||
|
type ConnectionChangeEvent = {
|
||||||
|
type: "connected" | "disconnected"
|
||||||
|
connection: ClientConnectionRecord
|
||||||
|
reason?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RegisteredConnection extends ClientConnectionRecord {
|
||||||
|
close: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ClientConnectionManager {
|
||||||
|
private readonly connections = new Map<string, RegisteredConnection>()
|
||||||
|
private readonly subscribers = new Set<(event: ConnectionChangeEvent) => void>()
|
||||||
|
private readonly sweepTimer: NodeJS.Timeout
|
||||||
|
|
||||||
|
constructor(private readonly logger: Logger) {
|
||||||
|
this.sweepTimer = setInterval(() => this.sweepStaleConnections(), STALE_SWEEP_INTERVAL_MS)
|
||||||
|
this.sweepTimer.unref?.()
|
||||||
|
}
|
||||||
|
|
||||||
|
shutdown(): void {
|
||||||
|
clearInterval(this.sweepTimer)
|
||||||
|
for (const connection of Array.from(this.connections.values())) {
|
||||||
|
this.disconnect(connection.key, "shutdown", false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
subscribe(listener: (event: ConnectionChangeEvent) => void): () => void {
|
||||||
|
this.subscribers.add(listener)
|
||||||
|
return () => this.subscribers.delete(listener)
|
||||||
|
}
|
||||||
|
|
||||||
|
register(input: ClientConnectionRef & { close: () => void }): () => void {
|
||||||
|
const key = getConnectionKey(input)
|
||||||
|
const now = Date.now()
|
||||||
|
const existing = this.connections.get(key)
|
||||||
|
|
||||||
|
if (existing) {
|
||||||
|
this.logger.debug({ clientId: input.clientId, connectionId: input.connectionId }, "Replacing existing client connection")
|
||||||
|
this.disconnect(key, "replaced")
|
||||||
|
}
|
||||||
|
|
||||||
|
const connection: RegisteredConnection = {
|
||||||
|
key,
|
||||||
|
clientId: input.clientId,
|
||||||
|
connectionId: input.connectionId,
|
||||||
|
connectedAt: now,
|
||||||
|
lastSeenAt: now,
|
||||||
|
close: input.close,
|
||||||
|
}
|
||||||
|
this.connections.set(key, connection)
|
||||||
|
this.logger.debug({ clientId: input.clientId, connectionId: input.connectionId }, "Client connected")
|
||||||
|
this.notify({ type: "connected", connection })
|
||||||
|
return () => this.disconnect(key, "closed")
|
||||||
|
}
|
||||||
|
|
||||||
|
pong(input: ClientConnectionRef): boolean {
|
||||||
|
const key = getConnectionKey(input)
|
||||||
|
const connection = this.connections.get(key)
|
||||||
|
if (!connection) {
|
||||||
|
this.logger.debug({ clientId: input.clientId, connectionId: input.connectionId }, "Ignoring pong for unknown client connection")
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
connection.lastSeenAt = Date.now()
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
isConnected(input: ClientConnectionRef): boolean {
|
||||||
|
return this.connections.has(getConnectionKey(input))
|
||||||
|
}
|
||||||
|
|
||||||
|
private sweepStaleConnections(): void {
|
||||||
|
const cutoff = Date.now() - STALE_CONNECTION_TIMEOUT_MS
|
||||||
|
for (const connection of Array.from(this.connections.values())) {
|
||||||
|
if (connection.lastSeenAt > cutoff) continue
|
||||||
|
this.logger.debug({ clientId: connection.clientId, connectionId: connection.connectionId }, "Client connection timed out")
|
||||||
|
this.disconnect(connection.key, "timeout")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private disconnect(key: string, reason: string, invokeClose = true): void {
|
||||||
|
const connection = this.connections.get(key)
|
||||||
|
if (!connection) return
|
||||||
|
this.connections.delete(key)
|
||||||
|
this.logger.debug({ clientId: connection.clientId, connectionId: connection.connectionId, reason }, "Client disconnected")
|
||||||
|
|
||||||
|
if (invokeClose) {
|
||||||
|
try {
|
||||||
|
connection.close()
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.warn({ err: error, clientId: connection.clientId, connectionId: connection.connectionId }, "Failed to close stale client connection")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
this.notify({ type: "disconnected", connection, reason })
|
||||||
|
}
|
||||||
|
|
||||||
|
private notify(event: ConnectionChangeEvent): void {
|
||||||
|
for (const subscriber of this.subscribers) {
|
||||||
|
try {
|
||||||
|
subscriber(event)
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.warn({ err: error, eventType: event.type }, "Client connection subscriber failed")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getConnectionKey(input: ClientConnectionRef): string {
|
||||||
|
return `${input.clientId}:${input.connectionId}`
|
||||||
|
}
|
||||||
@@ -26,6 +26,7 @@ const PreferencesSchema = z
|
|||||||
showUsageMetrics: z.boolean().default(true),
|
showUsageMetrics: z.boolean().default(true),
|
||||||
autoCleanupBlankSessions: z.boolean().default(true),
|
autoCleanupBlankSessions: z.boolean().default(true),
|
||||||
listeningMode: z.enum(["local", "all"]).default("local"),
|
listeningMode: z.enum(["local", "all"]).default("local"),
|
||||||
|
logLevel: z.enum(["DEBUG", "INFO", "WARN", "ERROR"]).default("DEBUG"),
|
||||||
|
|
||||||
// OS notifications
|
// OS notifications
|
||||||
osNotificationsEnabled: z.boolean().default(false),
|
osNotificationsEnabled: z.boolean().default(false),
|
||||||
|
|||||||
@@ -24,6 +24,8 @@ export class EventBus extends EventEmitter {
|
|||||||
this.on("workspace.error", handler)
|
this.on("workspace.error", handler)
|
||||||
this.on("workspace.stopped", handler)
|
this.on("workspace.stopped", handler)
|
||||||
this.on("workspace.log", handler)
|
this.on("workspace.log", handler)
|
||||||
|
this.on("sidecar.updated", handler)
|
||||||
|
this.on("sidecar.removed", handler)
|
||||||
this.on("storage.configChanged", handler)
|
this.on("storage.configChanged", handler)
|
||||||
this.on("storage.stateChanged", handler)
|
this.on("storage.stateChanged", handler)
|
||||||
this.on("instance.dataChanged", handler)
|
this.on("instance.dataChanged", handler)
|
||||||
@@ -35,6 +37,8 @@ export class EventBus extends EventEmitter {
|
|||||||
this.off("workspace.error", handler)
|
this.off("workspace.error", handler)
|
||||||
this.off("workspace.stopped", handler)
|
this.off("workspace.stopped", handler)
|
||||||
this.off("workspace.log", handler)
|
this.off("workspace.log", handler)
|
||||||
|
this.off("sidecar.updated", handler)
|
||||||
|
this.off("sidecar.removed", handler)
|
||||||
this.off("storage.configChanged", handler)
|
this.off("storage.configChanged", handler)
|
||||||
this.off("storage.stateChanged", handler)
|
this.off("storage.stateChanged", handler)
|
||||||
this.off("instance.dataChanged", handler)
|
this.off("instance.dataChanged", handler)
|
||||||
|
|||||||
@@ -19,11 +19,12 @@ import { InstanceEventBridge } from "./workspaces/instance-events"
|
|||||||
import { createLogger } from "./logger"
|
import { createLogger } from "./logger"
|
||||||
import { launchInBrowser } from "./launcher"
|
import { launchInBrowser } from "./launcher"
|
||||||
import { resolveUi } from "./ui/remote-ui"
|
import { resolveUi } from "./ui/remote-ui"
|
||||||
import { AuthManager, BOOTSTRAP_TOKEN_STDOUT_PREFIX, DEFAULT_AUTH_USERNAME } from "./auth/manager"
|
import { AuthManager, BOOTSTRAP_TOKEN_STDOUT_PREFIX, DEFAULT_AUTH_COOKIE_NAME, DEFAULT_AUTH_USERNAME } from "./auth/manager"
|
||||||
import { resolveHttpsOptions } from "./server/tls"
|
import { resolveHttpsOptions } from "./server/tls"
|
||||||
import { resolveNetworkAddresses } from "./server/network-addresses"
|
import { resolveNetworkAddresses, resolveRemoteAddresses } from "./server/network-addresses"
|
||||||
import { startDevReleaseMonitor } from "./releases/dev-release-monitor"
|
import { startDevReleaseMonitor } from "./releases/dev-release-monitor"
|
||||||
import { SpeechService } from "./speech/service"
|
import { SpeechService } from "./speech/service"
|
||||||
|
import { SideCarManager } from "./sidecars/manager"
|
||||||
|
|
||||||
const require = createRequire(import.meta.url)
|
const require = createRequire(import.meta.url)
|
||||||
|
|
||||||
@@ -55,6 +56,7 @@ interface CliOptions {
|
|||||||
launch: boolean
|
launch: boolean
|
||||||
authUsername: string
|
authUsername: string
|
||||||
authPassword?: string
|
authPassword?: string
|
||||||
|
authCookieName: string
|
||||||
generateToken: boolean
|
generateToken: boolean
|
||||||
dangerouslySkipAuth: boolean
|
dangerouslySkipAuth: boolean
|
||||||
}
|
}
|
||||||
@@ -100,6 +102,11 @@ function parseCliOptions(argv: string[]): CliOptions {
|
|||||||
.default(DEFAULT_AUTH_USERNAME),
|
.default(DEFAULT_AUTH_USERNAME),
|
||||||
)
|
)
|
||||||
.addOption(new Option("--password <password>", "Password for server authentication").env("CODENOMAD_SERVER_PASSWORD"))
|
.addOption(new Option("--password <password>", "Password for server authentication").env("CODENOMAD_SERVER_PASSWORD"))
|
||||||
|
.addOption(
|
||||||
|
new Option("--auth-cookie-name <name>", "Cookie name for server authentication")
|
||||||
|
.env("CODENOMAD_AUTH_COOKIE_NAME")
|
||||||
|
.default(DEFAULT_AUTH_COOKIE_NAME),
|
||||||
|
)
|
||||||
.addOption(
|
.addOption(
|
||||||
new Option("--generate-token", "Emit a one-time bootstrap token for desktop")
|
new Option("--generate-token", "Emit a one-time bootstrap token for desktop")
|
||||||
.env("CODENOMAD_GENERATE_TOKEN")
|
.env("CODENOMAD_GENERATE_TOKEN")
|
||||||
@@ -139,6 +146,7 @@ function parseCliOptions(argv: string[]): CliOptions {
|
|||||||
launch?: boolean
|
launch?: boolean
|
||||||
username: string
|
username: string
|
||||||
password?: string
|
password?: string
|
||||||
|
authCookieName: string
|
||||||
generateToken?: boolean
|
generateToken?: boolean
|
||||||
dangerouslySkipAuth?: boolean
|
dangerouslySkipAuth?: boolean
|
||||||
}>()
|
}>()
|
||||||
@@ -185,6 +193,7 @@ function parseCliOptions(argv: string[]): CliOptions {
|
|||||||
launch: Boolean(parsed.launch),
|
launch: Boolean(parsed.launch),
|
||||||
authUsername: parsed.username,
|
authUsername: parsed.username,
|
||||||
authPassword: parsed.password,
|
authPassword: parsed.password,
|
||||||
|
authCookieName: parsed.authCookieName,
|
||||||
generateToken: Boolean(parsed.generateToken),
|
generateToken: Boolean(parsed.generateToken),
|
||||||
dangerouslySkipAuth: Boolean(parsed.dangerouslySkipAuth),
|
dangerouslySkipAuth: Boolean(parsed.dangerouslySkipAuth),
|
||||||
}
|
}
|
||||||
@@ -266,6 +275,7 @@ async function main() {
|
|||||||
configPath: configLocation.configYamlPath,
|
configPath: configLocation.configYamlPath,
|
||||||
username: options.authUsername,
|
username: options.authUsername,
|
||||||
password: options.authPassword,
|
password: options.authPassword,
|
||||||
|
cookieName: options.authCookieName,
|
||||||
generateToken: options.generateToken,
|
generateToken: options.generateToken,
|
||||||
dangerouslySkipAuth: options.dangerouslySkipAuth,
|
dangerouslySkipAuth: options.dangerouslySkipAuth,
|
||||||
},
|
},
|
||||||
@@ -306,6 +316,11 @@ async function main() {
|
|||||||
const fileSystemBrowser = new FileSystemBrowser({ rootDir: options.rootDir, unrestricted: options.unrestrictedRoot })
|
const fileSystemBrowser = new FileSystemBrowser({ rootDir: options.rootDir, unrestricted: options.unrestrictedRoot })
|
||||||
const instanceStore = new InstanceStore(configLocation.instancesDir)
|
const instanceStore = new InstanceStore(configLocation.instancesDir)
|
||||||
const speechService = new SpeechService(settings, logger.child({ component: "speech" }))
|
const speechService = new SpeechService(settings, logger.child({ component: "speech" }))
|
||||||
|
const sidecarManager = new SideCarManager({
|
||||||
|
settings,
|
||||||
|
eventBus,
|
||||||
|
logger: logger.child({ component: "sidecars" }),
|
||||||
|
})
|
||||||
const instanceEventBridge = new InstanceEventBridge({
|
const instanceEventBridge = new InstanceEventBridge({
|
||||||
workspaceManager,
|
workspaceManager,
|
||||||
eventBus,
|
eventBus,
|
||||||
@@ -391,6 +406,7 @@ async function main() {
|
|||||||
serverMeta,
|
serverMeta,
|
||||||
instanceStore,
|
instanceStore,
|
||||||
speechService,
|
speechService,
|
||||||
|
sidecarManager,
|
||||||
authManager,
|
authManager,
|
||||||
uiStaticDir: uiResolution.uiStaticDir ?? DEFAULT_UI_STATIC_DIR,
|
uiStaticDir: uiResolution.uiStaticDir ?? DEFAULT_UI_STATIC_DIR,
|
||||||
uiDevServerUrl: uiResolution.uiDevServerUrl,
|
uiDevServerUrl: uiResolution.uiDevServerUrl,
|
||||||
@@ -412,6 +428,7 @@ async function main() {
|
|||||||
serverMeta,
|
serverMeta,
|
||||||
instanceStore,
|
instanceStore,
|
||||||
speechService,
|
speechService,
|
||||||
|
sidecarManager,
|
||||||
authManager,
|
authManager,
|
||||||
uiStaticDir: uiResolution.uiStaticDir ?? DEFAULT_UI_STATIC_DIR,
|
uiStaticDir: uiResolution.uiStaticDir ?? DEFAULT_UI_STATIC_DIR,
|
||||||
uiDevServerUrl: undefined,
|
uiDevServerUrl: undefined,
|
||||||
@@ -442,18 +459,22 @@ async function main() {
|
|||||||
// which can lead clients to talk to the wrong process.
|
// which can lead clients to talk to the wrong process.
|
||||||
const localUrl = `${localProtocol}://127.0.0.1:${localStart.port}`
|
const localUrl = `${localProtocol}://127.0.0.1:${localStart.port}`
|
||||||
let remoteUrl: string | undefined
|
let remoteUrl: string | undefined
|
||||||
|
let remoteAddresses = [] as ReturnType<typeof resolveNetworkAddresses>
|
||||||
if (remoteStart) {
|
if (remoteStart) {
|
||||||
const wantsAll = options.host === "0.0.0.0" || !isLoopbackHost(options.host)
|
const wantsAll = options.host === "0.0.0.0" || !isLoopbackHost(options.host)
|
||||||
let remoteHost = options.host
|
let remoteHost = options.host
|
||||||
if (wantsAll) {
|
if (wantsAll) {
|
||||||
if (options.host === "0.0.0.0") {
|
if (options.host === "0.0.0.0") {
|
||||||
const candidates = resolveNetworkAddresses({ host: options.host, protocol: remoteProtocol, port: remoteStart.port })
|
const resolved = resolveRemoteAddresses({ host: options.host, protocol: remoteProtocol, port: remoteStart.port })
|
||||||
remoteHost = candidates.find((addr) => addr.scope === "external")?.ip ?? "localhost"
|
remoteAddresses = resolved.userVisible
|
||||||
|
remoteUrl = resolved.primaryRemoteUrl ?? `${remoteProtocol}://localhost:${remoteStart.port}`
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
remoteHost = "localhost"
|
remoteHost = "localhost"
|
||||||
}
|
}
|
||||||
remoteUrl = `${remoteProtocol}://${remoteHost}:${remoteStart.port}`
|
if (!remoteUrl) {
|
||||||
|
remoteUrl = `${remoteProtocol}://${remoteHost}:${remoteStart.port}`
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
serverMeta.localUrl = localUrl
|
serverMeta.localUrl = localUrl
|
||||||
@@ -464,7 +485,9 @@ async function main() {
|
|||||||
serverMeta.listeningMode = options.host === "0.0.0.0" || !isLoopbackHost(options.host) ? "all" : "local"
|
serverMeta.listeningMode = options.host === "0.0.0.0" || !isLoopbackHost(options.host) ? "all" : "local"
|
||||||
|
|
||||||
if (serverMeta.remotePort && remoteUrl) {
|
if (serverMeta.remotePort && remoteUrl) {
|
||||||
serverMeta.addresses = resolveNetworkAddresses({ host: options.host, protocol: remoteProtocol, port: serverMeta.remotePort })
|
serverMeta.addresses = remoteAddresses.length
|
||||||
|
? remoteAddresses
|
||||||
|
: resolveNetworkAddresses({ host: options.host, protocol: remoteProtocol, port: serverMeta.remotePort })
|
||||||
} else {
|
} else {
|
||||||
serverMeta.addresses = []
|
serverMeta.addresses = []
|
||||||
}
|
}
|
||||||
@@ -472,6 +495,16 @@ async function main() {
|
|||||||
console.log(`Local Connection URL : ${serverMeta.localUrl}`)
|
console.log(`Local Connection URL : ${serverMeta.localUrl}`)
|
||||||
if (serverMeta.remoteUrl) {
|
if (serverMeta.remoteUrl) {
|
||||||
console.log(`Remote Connection URL : ${serverMeta.remoteUrl}`)
|
console.log(`Remote Connection URL : ${serverMeta.remoteUrl}`)
|
||||||
|
const additionalRemoteUrls = serverMeta.addresses
|
||||||
|
.map((addr) => addr.remoteUrl)
|
||||||
|
.filter((url) => url !== serverMeta.remoteUrl)
|
||||||
|
|
||||||
|
if (additionalRemoteUrls.length > 0) {
|
||||||
|
console.log("Other Accessible URLs:")
|
||||||
|
for (const url of additionalRemoteUrls) {
|
||||||
|
console.log(` - ${url}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (options.launch) {
|
if (options.launch) {
|
||||||
@@ -495,6 +528,12 @@ async function main() {
|
|||||||
logger.warn({ err: error }, "Instance event bridge shutdown failed")
|
logger.warn({ err: error }, "Instance event bridge shutdown failed")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await sidecarManager.shutdown()
|
||||||
|
} catch (error) {
|
||||||
|
logger.error({ err: error }, "SideCar manager shutdown failed")
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await workspaceManager.shutdown()
|
await workspaceManager.shutdown()
|
||||||
logger.info("Workspace manager shutdown complete")
|
logger.info("Workspace manager shutdown complete")
|
||||||
|
|||||||
96
packages/server/src/plugins/voice-mode.ts
Normal file
96
packages/server/src/plugins/voice-mode.ts
Normal file
@@ -0,0 +1,96 @@
|
|||||||
|
import type { Logger } from "../logger"
|
||||||
|
import type { ClientConnectionManager, ClientConnectionRef } from "../clients/connection-manager"
|
||||||
|
import type { PluginChannelManager } from "./channel"
|
||||||
|
|
||||||
|
interface VoiceModeManagerOptions {
|
||||||
|
connections: ClientConnectionManager
|
||||||
|
channel: PluginChannelManager
|
||||||
|
logger: Logger
|
||||||
|
}
|
||||||
|
|
||||||
|
export class VoiceModeManager {
|
||||||
|
private readonly enabledConnectionsByInstance = new Map<string, Set<string>>()
|
||||||
|
private readonly aggregateByInstance = new Map<string, boolean>()
|
||||||
|
|
||||||
|
constructor(private readonly options: VoiceModeManagerOptions) {
|
||||||
|
this.options.connections.subscribe((event) => {
|
||||||
|
if (event.type !== "disconnected") return
|
||||||
|
this.clearConnection(event.connection)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
setEnabled(instanceId: string, connection: ClientConnectionRef, enabled: boolean): void {
|
||||||
|
if (enabled && !this.options.connections.isConnected(connection)) {
|
||||||
|
this.options.logger.debug(
|
||||||
|
{ instanceId, clientId: connection.clientId, connectionId: connection.connectionId },
|
||||||
|
"Ignoring voice mode enable for disconnected client connection",
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const key = getConnectionKey(connection)
|
||||||
|
const current = this.enabledConnectionsByInstance.get(instanceId) ?? new Set<string>()
|
||||||
|
|
||||||
|
if (enabled) {
|
||||||
|
current.add(key)
|
||||||
|
this.enabledConnectionsByInstance.set(instanceId, current)
|
||||||
|
} else if (current.delete(key)) {
|
||||||
|
if (current.size === 0) {
|
||||||
|
this.enabledConnectionsByInstance.delete(instanceId)
|
||||||
|
} else {
|
||||||
|
this.enabledConnectionsByInstance.set(instanceId, current)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
this.options.logger.debug({ instanceId, clientId: connection.clientId, connectionId: connection.connectionId, enabled }, "Voice mode updated for client connection")
|
||||||
|
this.publishIfChanged(instanceId)
|
||||||
|
}
|
||||||
|
|
||||||
|
syncInstance(instanceId: string): void {
|
||||||
|
this.options.channel.send(instanceId, buildVoiceModeEvent(this.isEnabled(instanceId)))
|
||||||
|
}
|
||||||
|
|
||||||
|
isEnabled(instanceId: string): boolean {
|
||||||
|
return this.aggregateByInstance.get(instanceId) === true
|
||||||
|
}
|
||||||
|
|
||||||
|
private clearConnection(connection: ClientConnectionRef): void {
|
||||||
|
const key = getConnectionKey(connection)
|
||||||
|
for (const [instanceId, enabledConnections] of Array.from(this.enabledConnectionsByInstance.entries())) {
|
||||||
|
if (!enabledConnections.delete(key)) continue
|
||||||
|
if (enabledConnections.size === 0) {
|
||||||
|
this.enabledConnectionsByInstance.delete(instanceId)
|
||||||
|
}
|
||||||
|
this.publishIfChanged(instanceId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private publishIfChanged(instanceId: string): void {
|
||||||
|
const enabled = (this.enabledConnectionsByInstance.get(instanceId)?.size ?? 0) > 0
|
||||||
|
const previous = this.aggregateByInstance.get(instanceId) === true
|
||||||
|
if (enabled === previous) return
|
||||||
|
|
||||||
|
if (enabled) {
|
||||||
|
this.aggregateByInstance.set(instanceId, true)
|
||||||
|
} else {
|
||||||
|
this.aggregateByInstance.delete(instanceId)
|
||||||
|
}
|
||||||
|
|
||||||
|
this.options.logger.debug({ instanceId, enabled }, "Broadcasting aggregate voice mode")
|
||||||
|
this.options.channel.send(instanceId, buildVoiceModeEvent(enabled))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildVoiceModeEvent(enabled: boolean) {
|
||||||
|
return {
|
||||||
|
type: "codenomad.voiceMode",
|
||||||
|
properties: {
|
||||||
|
enabled,
|
||||||
|
formatVersion: "v1",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getConnectionKey(connection: ClientConnectionRef): string {
|
||||||
|
return `${connection.clientId}:${connection.connectionId}`
|
||||||
|
}
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
import assert from "node:assert/strict"
|
||||||
|
import os from "node:os"
|
||||||
|
import { describe, it } from "node:test"
|
||||||
|
|
||||||
|
import { resolveNetworkAddresses, resolveRemoteAddresses } from "../network-addresses"
|
||||||
|
|
||||||
|
describe("resolveNetworkAddresses", () => {
|
||||||
|
it("preserves interface order among external addresses", () => {
|
||||||
|
const addresses = [
|
||||||
|
{ address: "172.24.0.1", family: "IPv4", internal: false },
|
||||||
|
{ address: "192.168.1.128", family: "IPv4", internal: false },
|
||||||
|
{ address: "10.0.0.8", family: 4, internal: false },
|
||||||
|
{ address: "127.0.0.1", family: "IPv4", internal: true },
|
||||||
|
{ address: "169.254.10.20", family: "IPv4", internal: false },
|
||||||
|
]
|
||||||
|
|
||||||
|
usingMockedNetworkInterfaces(addresses, () => {
|
||||||
|
const result = resolveNetworkAddresses({ host: "0.0.0.0", protocol: "https", port: 9898 })
|
||||||
|
|
||||||
|
assert.deepEqual(
|
||||||
|
result.map((entry) => entry.ip),
|
||||||
|
["172.24.0.1", "192.168.1.128", "10.0.0.8", "169.254.10.20", "127.0.0.1"],
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("resolveRemoteAddresses", () => {
|
||||||
|
it("keeps all external addresses user-visible while preferring non-link-local addresses for the primary URL", () => {
|
||||||
|
const addresses = [
|
||||||
|
{ address: "169.254.10.20", family: "IPv4", internal: false },
|
||||||
|
{ address: "192.168.1.128", family: "IPv4", internal: false },
|
||||||
|
{ address: "172.24.0.1", family: "IPv4", internal: false },
|
||||||
|
]
|
||||||
|
|
||||||
|
usingMockedNetworkInterfaces(addresses, () => {
|
||||||
|
const result = resolveRemoteAddresses({ host: "0.0.0.0", protocol: "https", port: 9898 })
|
||||||
|
|
||||||
|
assert.deepEqual(
|
||||||
|
result.userVisible.map((entry) => entry.ip),
|
||||||
|
["192.168.1.128", "172.24.0.1", "169.254.10.20"],
|
||||||
|
)
|
||||||
|
assert.equal(result.primaryRemoteUrl, "https://192.168.1.128:9898")
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it("prefers private LAN addresses over public addresses", () => {
|
||||||
|
const addresses = [
|
||||||
|
{ address: "203.0.113.40", family: "IPv4", internal: false },
|
||||||
|
{ address: "192.168.1.128", family: "IPv4", internal: false },
|
||||||
|
{ address: "8.8.8.8", family: "IPv4", internal: false },
|
||||||
|
]
|
||||||
|
|
||||||
|
usingMockedNetworkInterfaces(addresses, () => {
|
||||||
|
const result = resolveRemoteAddresses({ host: "0.0.0.0", protocol: "https", port: 9898 })
|
||||||
|
|
||||||
|
assert.deepEqual(
|
||||||
|
result.userVisible.map((entry) => entry.ip),
|
||||||
|
["192.168.1.128", "203.0.113.40", "8.8.8.8"],
|
||||||
|
)
|
||||||
|
assert.equal(result.primaryRemoteUrl, "https://192.168.1.128:9898")
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it("uses a public address when no private LAN address is available", () => {
|
||||||
|
const addresses = [
|
||||||
|
{ address: "169.254.10.20", family: "IPv4", internal: false },
|
||||||
|
{ address: "203.0.113.40", family: "IPv4", internal: false },
|
||||||
|
]
|
||||||
|
|
||||||
|
usingMockedNetworkInterfaces(addresses, () => {
|
||||||
|
const result = resolveRemoteAddresses({ host: "0.0.0.0", protocol: "https", port: 9898 })
|
||||||
|
|
||||||
|
assert.deepEqual(result.userVisible.map((entry) => entry.ip), ["203.0.113.40", "169.254.10.20"])
|
||||||
|
assert.equal(result.primaryRemoteUrl, "https://203.0.113.40:9898")
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
function usingMockedNetworkInterfaces(
|
||||||
|
addresses: Array<{ address: string; family: string | number; internal: boolean }>,
|
||||||
|
callback: () => void,
|
||||||
|
) {
|
||||||
|
const original = os.networkInterfaces
|
||||||
|
os.networkInterfaces = (() => ({
|
||||||
|
ethernet0: addresses as unknown as ReturnType<typeof os.networkInterfaces>[string],
|
||||||
|
})) as typeof os.networkInterfaces
|
||||||
|
|
||||||
|
try {
|
||||||
|
callback()
|
||||||
|
} finally {
|
||||||
|
os.networkInterfaces = original
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,7 +3,9 @@ import cors from "@fastify/cors"
|
|||||||
import fastifyStatic from "@fastify/static"
|
import fastifyStatic from "@fastify/static"
|
||||||
import replyFrom from "@fastify/reply-from"
|
import replyFrom from "@fastify/reply-from"
|
||||||
import fs from "fs"
|
import fs from "fs"
|
||||||
|
import { connect as connectTcp, type Socket } from "net"
|
||||||
import path from "path"
|
import path from "path"
|
||||||
|
import { connect as connectTls, type TLSSocket } from "tls"
|
||||||
import { fetch } from "undici"
|
import { fetch } from "undici"
|
||||||
import type { Logger } from "../logger"
|
import type { Logger } from "../logger"
|
||||||
import { WorkspaceManager } from "../workspaces/manager"
|
import { WorkspaceManager } from "../workspaces/manager"
|
||||||
@@ -22,6 +24,8 @@ import { registerPluginRoutes } from "./routes/plugin"
|
|||||||
import { registerBackgroundProcessRoutes } from "./routes/background-processes"
|
import { registerBackgroundProcessRoutes } from "./routes/background-processes"
|
||||||
import { registerWorktreeRoutes } from "./routes/worktrees"
|
import { registerWorktreeRoutes } from "./routes/worktrees"
|
||||||
import { registerSpeechRoutes } from "./routes/speech"
|
import { registerSpeechRoutes } from "./routes/speech"
|
||||||
|
import { registerRemoteServerRoutes } from "./routes/remote-servers"
|
||||||
|
import { registerSideCarRoutes } from "./routes/sidecars"
|
||||||
import { ServerMeta } from "../api-types"
|
import { ServerMeta } from "../api-types"
|
||||||
import { InstanceStore } from "../storage/instance-store"
|
import { InstanceStore } from "../storage/instance-store"
|
||||||
import { BackgroundProcessManager } from "../background-processes/manager"
|
import { BackgroundProcessManager } from "../background-processes/manager"
|
||||||
@@ -29,7 +33,10 @@ import type { AuthManager } from "../auth/manager"
|
|||||||
import { registerAuthRoutes } from "./routes/auth"
|
import { registerAuthRoutes } from "./routes/auth"
|
||||||
import { sendUnauthorized, wantsHtml } from "../auth/http-auth"
|
import { sendUnauthorized, wantsHtml } from "../auth/http-auth"
|
||||||
import type { SpeechService } from "../speech/service"
|
import type { SpeechService } from "../speech/service"
|
||||||
|
import { ClientConnectionManager } from "../clients/connection-manager"
|
||||||
import { PluginChannelManager } from "../plugins/channel"
|
import { PluginChannelManager } from "../plugins/channel"
|
||||||
|
import { VoiceModeManager } from "../plugins/voice-mode"
|
||||||
|
import type { SideCarManager } from "../sidecars/manager"
|
||||||
|
|
||||||
interface HttpServerDeps {
|
interface HttpServerDeps {
|
||||||
bindHost: string
|
bindHost: string
|
||||||
@@ -45,6 +52,7 @@ interface HttpServerDeps {
|
|||||||
serverMeta: ServerMeta
|
serverMeta: ServerMeta
|
||||||
instanceStore: InstanceStore
|
instanceStore: InstanceStore
|
||||||
speechService: SpeechService
|
speechService: SpeechService
|
||||||
|
sidecarManager: SideCarManager
|
||||||
authManager: AuthManager
|
authManager: AuthManager
|
||||||
uiStaticDir: string
|
uiStaticDir: string
|
||||||
uiDevServerUrl?: string
|
uiDevServerUrl?: string
|
||||||
@@ -174,7 +182,13 @@ export function createHttpServer(deps: HttpServerDeps) {
|
|||||||
eventBus: deps.eventBus,
|
eventBus: deps.eventBus,
|
||||||
logger: deps.logger.child({ component: "background-processes" }),
|
logger: deps.logger.child({ component: "background-processes" }),
|
||||||
})
|
})
|
||||||
|
const clientConnectionManager = new ClientConnectionManager(deps.logger.child({ component: "client-connections" }))
|
||||||
const pluginChannel = new PluginChannelManager(deps.logger.child({ component: "plugin-channel" }))
|
const pluginChannel = new PluginChannelManager(deps.logger.child({ component: "plugin-channel" }))
|
||||||
|
const voiceModeManager = new VoiceModeManager({
|
||||||
|
connections: clientConnectionManager,
|
||||||
|
channel: pluginChannel,
|
||||||
|
logger: deps.logger.child({ component: "voice-mode" }),
|
||||||
|
})
|
||||||
|
|
||||||
registerAuthRoutes(app, { authManager: deps.authManager })
|
registerAuthRoutes(app, { authManager: deps.authManager })
|
||||||
|
|
||||||
@@ -195,7 +209,7 @@ export function createHttpServer(deps: HttpServerDeps) {
|
|||||||
|
|
||||||
const session = deps.authManager.getSessionFromRequest(request)
|
const session = deps.authManager.getSessionFromRequest(request)
|
||||||
|
|
||||||
const requiresAuthForApi = pathname.startsWith("/api/") || pathname.startsWith("/workspaces/")
|
const requiresAuthForApi = pathname.startsWith("/api/") || pathname.startsWith("/workspaces/") || pathname.startsWith("/sidecars/")
|
||||||
if (requiresAuthForApi && !session) {
|
if (requiresAuthForApi && !session) {
|
||||||
// Allow OpenCode plugin -> CodeNomad calls with per-instance basic auth.
|
// Allow OpenCode plugin -> CodeNomad calls with per-instance basic auth.
|
||||||
const pluginMatch = pathname.match(/^\/workspaces\/([^/]+)\/plugin(?:\/|$)/)
|
const pluginMatch = pathname.match(/^\/workspaces\/([^/]+)\/plugin(?:\/|$)/)
|
||||||
@@ -250,19 +264,33 @@ export function createHttpServer(deps: HttpServerDeps) {
|
|||||||
registerSettingsRoutes(app, { settings: deps.settings, logger: apiLogger })
|
registerSettingsRoutes(app, { settings: deps.settings, logger: apiLogger })
|
||||||
registerFilesystemRoutes(app, { fileSystemBrowser: deps.fileSystemBrowser })
|
registerFilesystemRoutes(app, { fileSystemBrowser: deps.fileSystemBrowser })
|
||||||
registerMetaRoutes(app, { serverMeta: deps.serverMeta })
|
registerMetaRoutes(app, { serverMeta: deps.serverMeta })
|
||||||
registerEventRoutes(app, { eventBus: deps.eventBus, registerClient: registerSseClient, logger: sseLogger })
|
registerEventRoutes(app, {
|
||||||
|
eventBus: deps.eventBus,
|
||||||
|
registerClient: registerSseClient,
|
||||||
|
logger: sseLogger,
|
||||||
|
connectionManager: clientConnectionManager,
|
||||||
|
})
|
||||||
registerWorktreeRoutes(app, { workspaceManager: deps.workspaceManager })
|
registerWorktreeRoutes(app, { workspaceManager: deps.workspaceManager })
|
||||||
registerStorageRoutes(app, {
|
registerStorageRoutes(app, {
|
||||||
instanceStore: deps.instanceStore,
|
instanceStore: deps.instanceStore,
|
||||||
eventBus: deps.eventBus,
|
eventBus: deps.eventBus,
|
||||||
workspaceManager: deps.workspaceManager,
|
workspaceManager: deps.workspaceManager,
|
||||||
})
|
})
|
||||||
|
registerRemoteServerRoutes(app, { logger: apiLogger })
|
||||||
registerSpeechRoutes(app, { speechService: deps.speechService })
|
registerSpeechRoutes(app, { speechService: deps.speechService })
|
||||||
|
registerSideCarRoutes(app, { sidecarManager: deps.sidecarManager })
|
||||||
|
registerSideCarProxyRoutes(app, { sidecarManager: deps.sidecarManager, logger: proxyLogger })
|
||||||
|
setupSideCarWebSocketProxy(app, {
|
||||||
|
sidecarManager: deps.sidecarManager,
|
||||||
|
authManager: deps.authManager,
|
||||||
|
logger: proxyLogger,
|
||||||
|
})
|
||||||
registerPluginRoutes(app, {
|
registerPluginRoutes(app, {
|
||||||
workspaceManager: deps.workspaceManager,
|
workspaceManager: deps.workspaceManager,
|
||||||
eventBus: deps.eventBus,
|
eventBus: deps.eventBus,
|
||||||
logger: proxyLogger,
|
logger: proxyLogger,
|
||||||
channel: pluginChannel,
|
channel: pluginChannel,
|
||||||
|
voiceModeManager,
|
||||||
})
|
})
|
||||||
registerBackgroundProcessRoutes(app, { backgroundProcessManager })
|
registerBackgroundProcessRoutes(app, { backgroundProcessManager })
|
||||||
registerInstanceProxyRoutes(app, { workspaceManager: deps.workspaceManager, logger: proxyLogger })
|
registerInstanceProxyRoutes(app, { workspaceManager: deps.workspaceManager, logger: proxyLogger })
|
||||||
@@ -328,6 +356,7 @@ export function createHttpServer(deps: HttpServerDeps) {
|
|||||||
},
|
},
|
||||||
stop: () => {
|
stop: () => {
|
||||||
closeSseClients()
|
closeSseClients()
|
||||||
|
clientConnectionManager.shutdown()
|
||||||
return app.close()
|
return app.close()
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
@@ -338,6 +367,68 @@ interface InstanceProxyDeps {
|
|||||||
logger: Logger
|
logger: Logger
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface SideCarProxyDeps {
|
||||||
|
sidecarManager: SideCarManager
|
||||||
|
logger: Logger
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SideCarWebSocketProxyDeps extends SideCarProxyDeps {
|
||||||
|
authManager: AuthManager
|
||||||
|
}
|
||||||
|
|
||||||
|
function registerSideCarProxyRoutes(app: FastifyInstance, deps: SideCarProxyDeps) {
|
||||||
|
const proxyBaseHandler = async (
|
||||||
|
request: FastifyRequest<{ Params: { id: string } }>,
|
||||||
|
reply: FastifyReply,
|
||||||
|
) => {
|
||||||
|
await proxySideCarRequest({
|
||||||
|
request,
|
||||||
|
reply,
|
||||||
|
sidecarManager: deps.sidecarManager,
|
||||||
|
logger: deps.logger,
|
||||||
|
pathSuffix: "",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const proxyWildcardHandler = async (
|
||||||
|
request: FastifyRequest<{ Params: { id: string; "*": string } }>,
|
||||||
|
reply: FastifyReply,
|
||||||
|
) => {
|
||||||
|
await proxySideCarRequest({
|
||||||
|
request,
|
||||||
|
reply,
|
||||||
|
sidecarManager: deps.sidecarManager,
|
||||||
|
logger: deps.logger,
|
||||||
|
pathSuffix: request.params["*"] ?? "",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
app.all("/sidecars/:id", proxyBaseHandler)
|
||||||
|
app.all("/sidecars/:id/*", proxyWildcardHandler)
|
||||||
|
}
|
||||||
|
|
||||||
|
function setupSideCarWebSocketProxy(app: FastifyInstance, deps: SideCarWebSocketProxyDeps) {
|
||||||
|
app.server.on("upgrade", (request, socket, head) => {
|
||||||
|
const rawUrl = request.url ?? "/"
|
||||||
|
const parsed = parseSideCarUpgradePath(rawUrl)
|
||||||
|
if (!parsed) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
void proxySideCarWebSocketUpgrade({
|
||||||
|
request,
|
||||||
|
socket: socket as Socket,
|
||||||
|
head,
|
||||||
|
sidecarId: parsed.sidecarId,
|
||||||
|
incomingPath: parsed.pathname,
|
||||||
|
search: parsed.search,
|
||||||
|
sidecarManager: deps.sidecarManager,
|
||||||
|
authManager: deps.authManager,
|
||||||
|
logger: deps.logger,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
function registerInstanceProxyRoutes(app: FastifyInstance, deps: InstanceProxyDeps) {
|
function registerInstanceProxyRoutes(app: FastifyInstance, deps: InstanceProxyDeps) {
|
||||||
app.register(async (instance) => {
|
app.register(async (instance) => {
|
||||||
instance.removeAllContentTypeParsers()
|
instance.removeAllContentTypeParsers()
|
||||||
@@ -822,3 +913,281 @@ function buildProxyHeaders(headers: FastifyRequest["headers"]): Record<string, s
|
|||||||
}
|
}
|
||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function proxySideCarRequest(args: {
|
||||||
|
request: FastifyRequest
|
||||||
|
reply: FastifyReply
|
||||||
|
sidecarManager: SideCarManager
|
||||||
|
logger: Logger
|
||||||
|
pathSuffix?: string
|
||||||
|
}) {
|
||||||
|
const sidecarId = (args.request.params as { id?: string }).id ?? ""
|
||||||
|
const sidecar = await args.sidecarManager.get(sidecarId)
|
||||||
|
if (!sidecar) {
|
||||||
|
args.reply.code(404).send({ error: "SideCar not found" })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const pathname = (args.request.raw.url ?? args.request.url ?? "").split("?")[0] ?? ""
|
||||||
|
const queryIndex = (args.request.raw.url ?? args.request.url ?? "").indexOf("?")
|
||||||
|
const search = queryIndex >= 0 ? (args.request.raw.url ?? args.request.url ?? "").slice(queryIndex) : ""
|
||||||
|
const pathSuffix = args.pathSuffix ?? ""
|
||||||
|
const requestPath = pathSuffix ? `${args.sidecarManager.buildProxyBasePath(sidecarId)}/${pathSuffix.replace(/^\/+/, "")}` : args.sidecarManager.buildProxyBasePath(sidecarId)
|
||||||
|
const targetPath = args.sidecarManager.buildTargetPath(sidecarId, requestPath, search)
|
||||||
|
const targetOrigin = args.sidecarManager.buildTargetOrigin(sidecar)
|
||||||
|
const targetUrl = `${targetOrigin}${targetPath}`
|
||||||
|
args.logger.debug({ sidecarId: sidecar.id, targetUrl, pathname, prefixMode: sidecar.prefixMode }, "Proxying request to SideCar")
|
||||||
|
|
||||||
|
await args.reply.from(targetUrl, {
|
||||||
|
rewriteRequestHeaders: (_originalRequest, headers) =>
|
||||||
|
sanitizeSideCarProxyRequestHeaders(headers as Record<string, string | string[] | undefined>, targetOrigin),
|
||||||
|
rewriteHeaders: (headers) => rewriteSideCarResponseHeaders(headers, sidecarId, targetOrigin, sidecar.prefixMode),
|
||||||
|
onError: (reply, { error }) => {
|
||||||
|
args.logger.error({ sidecarId: sidecar.id, err: error, targetUrl }, "Failed to proxy SideCar request")
|
||||||
|
if (!reply.sent) {
|
||||||
|
reply.code(502).send({ error: "SideCar proxy failed" })
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseSideCarUpgradePath(rawUrl: string): { sidecarId: string; pathname: string; search: string } | null {
|
||||||
|
let parsed: URL
|
||||||
|
try {
|
||||||
|
parsed = new URL(rawUrl, "http://localhost")
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const match = parsed.pathname.match(/^\/sidecars\/([^/]+)(?:\/.*)?$/)
|
||||||
|
if (!match) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
return {
|
||||||
|
sidecarId: decodeURIComponent(match[1] ?? ""),
|
||||||
|
pathname: parsed.pathname,
|
||||||
|
search: parsed.search,
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function proxySideCarWebSocketUpgrade(args: {
|
||||||
|
request: import("http").IncomingMessage
|
||||||
|
socket: Socket
|
||||||
|
head: Buffer
|
||||||
|
sidecarId: string
|
||||||
|
incomingPath: string
|
||||||
|
search: string
|
||||||
|
sidecarManager: SideCarManager
|
||||||
|
authManager: AuthManager
|
||||||
|
logger: Logger
|
||||||
|
}) {
|
||||||
|
const { request, socket, head, sidecarId, incomingPath, search, sidecarManager, authManager, logger } = args
|
||||||
|
|
||||||
|
if (!isWebSocketUpgradeRequest(request)) {
|
||||||
|
rejectUpgrade(socket, 400, "Bad Request")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const session = authManager.getSessionFromHeaders(request.headers)
|
||||||
|
if (!session) {
|
||||||
|
rejectUpgrade(socket, 401, "Unauthorized")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const sidecar = await sidecarManager.get(sidecarId)
|
||||||
|
if (!sidecar) {
|
||||||
|
rejectUpgrade(socket, 404, "Not Found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const targetOrigin = sidecarManager.buildTargetOrigin(sidecar)
|
||||||
|
const targetPath = sidecarManager.buildTargetPath(sidecarId, incomingPath, search)
|
||||||
|
const targetUrl = new URL(`${targetOrigin}${targetPath}`)
|
||||||
|
logger.debug({ sidecarId, targetUrl: targetUrl.toString(), prefixMode: sidecar.prefixMode }, "Proxying websocket to SideCar")
|
||||||
|
|
||||||
|
const { socket: upstream, readyEvent } = createSideCarUpstreamSocket(targetUrl)
|
||||||
|
|
||||||
|
const closeBoth = () => {
|
||||||
|
if (!socket.destroyed) {
|
||||||
|
socket.destroy()
|
||||||
|
}
|
||||||
|
if (!upstream.destroyed) {
|
||||||
|
upstream.destroy()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
upstream.once("error", (error) => {
|
||||||
|
logger.error({ sidecarId, err: error, targetUrl: targetUrl.toString() }, "Failed to proxy SideCar websocket")
|
||||||
|
rejectUpgrade(socket, 502, "Bad Gateway")
|
||||||
|
if (!upstream.destroyed) {
|
||||||
|
upstream.destroy()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
socket.once("error", (error) => {
|
||||||
|
logger.debug({ sidecarId, err: error }, "SideCar websocket client socket errored")
|
||||||
|
if (!upstream.destroyed) {
|
||||||
|
upstream.destroy()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
upstream.once(readyEvent, () => {
|
||||||
|
try {
|
||||||
|
upstream.write(buildSideCarWebSocketRequest(request, targetUrl))
|
||||||
|
if (head.length > 0) {
|
||||||
|
upstream.write(head)
|
||||||
|
}
|
||||||
|
upstream.pipe(socket)
|
||||||
|
socket.pipe(upstream)
|
||||||
|
} catch (error) {
|
||||||
|
logger.error({ sidecarId, err: error, targetUrl: targetUrl.toString() }, "Failed to forward SideCar websocket upgrade")
|
||||||
|
closeBoth()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
upstream.once("close", () => {
|
||||||
|
if (!socket.destroyed) {
|
||||||
|
socket.end()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
socket.once("close", () => {
|
||||||
|
if (!upstream.destroyed) {
|
||||||
|
upstream.end()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function createSideCarUpstreamSocket(targetUrl: URL): { socket: Socket | TLSSocket; readyEvent: "connect" | "secureConnect" } {
|
||||||
|
const port = Number(targetUrl.port || (targetUrl.protocol === "https:" ? 443 : 80))
|
||||||
|
if (targetUrl.protocol === "https:") {
|
||||||
|
return {
|
||||||
|
socket: connectTls({
|
||||||
|
host: targetUrl.hostname,
|
||||||
|
port,
|
||||||
|
servername: targetUrl.hostname,
|
||||||
|
}),
|
||||||
|
readyEvent: "secureConnect",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
socket: connectTcp(port, targetUrl.hostname),
|
||||||
|
readyEvent: "connect",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildSideCarWebSocketRequest(request: import("http").IncomingMessage, targetUrl: URL): string {
|
||||||
|
const pathWithQuery = `${targetUrl.pathname}${targetUrl.search}`
|
||||||
|
const requestLine = `${request.method ?? "GET"} ${pathWithQuery} HTTP/${request.httpVersion}\r\n`
|
||||||
|
const headerLines: string[] = []
|
||||||
|
const rawHeaders = request.rawHeaders ?? []
|
||||||
|
const blockedHeaders = getBlockedSideCarRequestHeaders()
|
||||||
|
|
||||||
|
for (let index = 0; index < rawHeaders.length; index += 2) {
|
||||||
|
const key = rawHeaders[index]
|
||||||
|
const value = rawHeaders[index + 1]
|
||||||
|
if (!key || value === undefined) continue
|
||||||
|
const lower = key.toLowerCase()
|
||||||
|
if (blockedHeaders.has(lower)) continue
|
||||||
|
if (lower === "origin") {
|
||||||
|
headerLines.push(`Origin: ${targetUrl.origin}\r\n`)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
headerLines.push(`${key}: ${value}\r\n`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const hostValue = targetUrl.port ? `${targetUrl.hostname}:${targetUrl.port}` : targetUrl.hostname
|
||||||
|
headerLines.push(`Host: ${hostValue}\r\n`)
|
||||||
|
headerLines.push("\r\n")
|
||||||
|
|
||||||
|
return requestLine + headerLines.join("")
|
||||||
|
}
|
||||||
|
|
||||||
|
function isWebSocketUpgradeRequest(request: import("http").IncomingMessage): boolean {
|
||||||
|
const upgrade = request.headers.upgrade
|
||||||
|
if (typeof upgrade !== "string" || upgrade.toLowerCase() !== "websocket") {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
const connection = request.headers.connection
|
||||||
|
const connectionValue = Array.isArray(connection) ? connection.join(",") : connection ?? ""
|
||||||
|
return connectionValue.toLowerCase().split(",").map((part) => part.trim()).includes("upgrade")
|
||||||
|
}
|
||||||
|
|
||||||
|
function rejectUpgrade(socket: Socket, statusCode: number, statusText: string) {
|
||||||
|
if (socket.destroyed) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
socket.write(`HTTP/1.1 ${statusCode} ${statusText}\r\nConnection: close\r\nContent-Length: 0\r\n\r\n`)
|
||||||
|
socket.destroy()
|
||||||
|
}
|
||||||
|
|
||||||
|
function rewriteSideCarResponseHeaders(
|
||||||
|
headers: Record<string, string | string[] | undefined>,
|
||||||
|
sidecarId: string,
|
||||||
|
targetOrigin: string,
|
||||||
|
prefixMode: "strip" | "preserve",
|
||||||
|
) {
|
||||||
|
if (prefixMode === "preserve") {
|
||||||
|
return headers
|
||||||
|
}
|
||||||
|
|
||||||
|
const next = { ...headers }
|
||||||
|
const locationHeader = next.location
|
||||||
|
const location = Array.isArray(locationHeader) ? locationHeader[0] : locationHeader
|
||||||
|
if (!location) {
|
||||||
|
return next
|
||||||
|
}
|
||||||
|
|
||||||
|
const publicBase = `/sidecars/${encodeURIComponent(sidecarId)}`
|
||||||
|
|
||||||
|
if (location.startsWith("/")) {
|
||||||
|
next.location = `${publicBase}${location}`
|
||||||
|
return next
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const parsed = new URL(location)
|
||||||
|
if (parsed.origin === targetOrigin) {
|
||||||
|
next.location = `${publicBase}${parsed.pathname}${parsed.search}${parsed.hash}`
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Relative redirects should continue to resolve against the public sidecar path.
|
||||||
|
}
|
||||||
|
|
||||||
|
return next
|
||||||
|
}
|
||||||
|
|
||||||
|
function sanitizeSideCarProxyRequestHeaders(
|
||||||
|
headers: Record<string, string | string[] | undefined>,
|
||||||
|
targetOrigin: string,
|
||||||
|
): Record<string, string | string[] | undefined> {
|
||||||
|
const blockedHeaders = getBlockedSideCarRequestHeaders()
|
||||||
|
const next: Record<string, string | string[] | undefined> = {}
|
||||||
|
|
||||||
|
for (const [key, value] of Object.entries(headers)) {
|
||||||
|
if (!value) continue
|
||||||
|
if (blockedHeaders.has(key.toLowerCase())) continue
|
||||||
|
next[key] = value
|
||||||
|
}
|
||||||
|
|
||||||
|
next.origin = targetOrigin
|
||||||
|
return next
|
||||||
|
}
|
||||||
|
|
||||||
|
function getBlockedSideCarRequestHeaders(): Set<string> {
|
||||||
|
return new Set([
|
||||||
|
"host",
|
||||||
|
"authorization",
|
||||||
|
"proxy-authorization",
|
||||||
|
"forwarded",
|
||||||
|
"x-forwarded-for",
|
||||||
|
"x-forwarded-host",
|
||||||
|
"x-forwarded-port",
|
||||||
|
"x-forwarded-proto",
|
||||||
|
])
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,6 +1,12 @@
|
|||||||
import os from "os"
|
import os from "os"
|
||||||
import type { NetworkAddress } from "../api-types"
|
import type { NetworkAddress } from "../api-types"
|
||||||
|
|
||||||
|
export interface ResolvedRemoteAddresses {
|
||||||
|
all: NetworkAddress[]
|
||||||
|
userVisible: NetworkAddress[]
|
||||||
|
primaryRemoteUrl?: string
|
||||||
|
}
|
||||||
|
|
||||||
export function resolveNetworkAddresses(args: {
|
export function resolveNetworkAddresses(args: {
|
||||||
host: string
|
host: string
|
||||||
protocol: "http" | "https"
|
protocol: "http" | "https"
|
||||||
@@ -58,10 +64,57 @@ export function resolveNetworkAddresses(args: {
|
|||||||
return results.sort((a, b) => {
|
return results.sort((a, b) => {
|
||||||
const scopeDelta = scopeWeight[a.scope] - scopeWeight[b.scope]
|
const scopeDelta = scopeWeight[a.scope] - scopeWeight[b.scope]
|
||||||
if (scopeDelta !== 0) return scopeDelta
|
if (scopeDelta !== 0) return scopeDelta
|
||||||
return a.ip.localeCompare(b.ip)
|
|
||||||
|
return 0
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function resolveRemoteAddresses(args: {
|
||||||
|
host: string
|
||||||
|
protocol: "http" | "https"
|
||||||
|
port: number
|
||||||
|
}): ResolvedRemoteAddresses {
|
||||||
|
const all = resolveNetworkAddresses(args)
|
||||||
|
const userVisible = sortUserVisibleAddresses(all.filter((address) => address.scope === "external"))
|
||||||
|
return {
|
||||||
|
all,
|
||||||
|
userVisible,
|
||||||
|
primaryRemoteUrl: userVisible[0]?.remoteUrl,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function sortUserVisibleAddresses(addresses: NetworkAddress[]): NetworkAddress[] {
|
||||||
|
return [...addresses].sort((left, right) => getUserVisiblePriority(left.ip) - getUserVisiblePriority(right.ip))
|
||||||
|
}
|
||||||
|
|
||||||
|
function getUserVisiblePriority(ip: string): number {
|
||||||
|
if (isPrivateIPv4(ip)) return 0
|
||||||
|
if (isLinkLocalIPv4(ip)) return 2
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
function isLinkLocalIPv4(ip: string): boolean {
|
||||||
|
const octets = parseIPv4(ip)
|
||||||
|
if (!octets) return false
|
||||||
|
const [first, second] = octets
|
||||||
|
return first === 169 && second === 254
|
||||||
|
}
|
||||||
|
|
||||||
|
function isPrivateIPv4(ip: string): boolean {
|
||||||
|
const octets = parseIPv4(ip)
|
||||||
|
if (!octets) return false
|
||||||
|
const [first, second] = octets
|
||||||
|
|
||||||
|
if (first === 10) return true
|
||||||
|
if (first === 192 && second === 168) return true
|
||||||
|
return first === 172 && second >= 16 && second <= 31
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseIPv4(value: string): number[] | null {
|
||||||
|
if (!isIPv4Address(value)) return null
|
||||||
|
return value.split(".").map((part) => Number(part))
|
||||||
|
}
|
||||||
|
|
||||||
function isIPv4Address(value: string | undefined): value is string {
|
function isIPv4Address(value: string | undefined): value is string {
|
||||||
if (!value) return false
|
if (!value) return false
|
||||||
const parts = value.split(".")
|
const parts = value.split(".")
|
||||||
|
|||||||
@@ -1,19 +1,32 @@
|
|||||||
import { FastifyInstance } from "fastify"
|
import { FastifyInstance } from "fastify"
|
||||||
|
import { z } from "zod"
|
||||||
import { EventBus } from "../../events/bus"
|
import { EventBus } from "../../events/bus"
|
||||||
import { WorkspaceEventPayload } from "../../api-types"
|
import { WorkspaceEventPayload } from "../../api-types"
|
||||||
|
import type { ClientConnectionManager } from "../../clients/connection-manager"
|
||||||
import { Logger } from "../../logger"
|
import { Logger } from "../../logger"
|
||||||
|
|
||||||
interface RouteDeps {
|
interface RouteDeps {
|
||||||
eventBus: EventBus
|
eventBus: EventBus
|
||||||
registerClient: (cleanup: () => void) => () => void
|
registerClient: (cleanup: () => void) => () => void
|
||||||
logger: Logger
|
logger: Logger
|
||||||
|
connectionManager: ClientConnectionManager
|
||||||
}
|
}
|
||||||
|
|
||||||
let nextClientId = 0
|
let nextClientId = 0
|
||||||
|
|
||||||
|
const ConnectionQuerySchema = z.object({
|
||||||
|
clientId: z.string().trim().min(1),
|
||||||
|
connectionId: z.string().trim().min(1),
|
||||||
|
})
|
||||||
|
|
||||||
|
const PongBodySchema = ConnectionQuerySchema.extend({
|
||||||
|
pingTs: z.number().optional(),
|
||||||
|
})
|
||||||
|
|
||||||
export function registerEventRoutes(app: FastifyInstance, deps: RouteDeps) {
|
export function registerEventRoutes(app: FastifyInstance, deps: RouteDeps) {
|
||||||
app.get("/api/events", (request, reply) => {
|
app.get("/api/events", (request, reply) => {
|
||||||
const clientId = ++nextClientId
|
const clientId = ++nextClientId
|
||||||
|
const connection = ConnectionQuerySchema.parse(request.query ?? {})
|
||||||
deps.logger.debug({ clientId }, "SSE client connected")
|
deps.logger.debug({ clientId }, "SSE client connected")
|
||||||
|
|
||||||
const origin = request.headers.origin ?? "*"
|
const origin = request.headers.origin ?? "*"
|
||||||
@@ -35,7 +48,8 @@ export function registerEventRoutes(app: FastifyInstance, deps: RouteDeps) {
|
|||||||
|
|
||||||
const unsubscribe = deps.eventBus.onEvent(send)
|
const unsubscribe = deps.eventBus.onEvent(send)
|
||||||
const heartbeat = setInterval(() => {
|
const heartbeat = setInterval(() => {
|
||||||
reply.raw.write(`:hb ${Date.now()}\n\n`)
|
const ping = { ts: Date.now() }
|
||||||
|
reply.raw.write(`event: codenomad.client.ping\ndata: ${JSON.stringify(ping)}\n\n`)
|
||||||
}, 15000)
|
}, 15000)
|
||||||
|
|
||||||
let closed = false
|
let closed = false
|
||||||
@@ -49,13 +63,27 @@ export function registerEventRoutes(app: FastifyInstance, deps: RouteDeps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const unregister = deps.registerClient(close)
|
const unregister = deps.registerClient(close)
|
||||||
|
const unregisterConnection = deps.connectionManager.register({
|
||||||
|
...connection,
|
||||||
|
close,
|
||||||
|
})
|
||||||
|
|
||||||
const handleClose = () => {
|
const handleClose = () => {
|
||||||
close()
|
close()
|
||||||
unregister()
|
unregister()
|
||||||
|
unregisterConnection()
|
||||||
}
|
}
|
||||||
|
|
||||||
request.raw.on("close", handleClose)
|
request.raw.on("close", handleClose)
|
||||||
request.raw.on("error", handleClose)
|
request.raw.on("error", handleClose)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
app.post("/api/client-connections/pong", (request, reply) => {
|
||||||
|
const body = PongBodySchema.parse(request.body ?? {})
|
||||||
|
if (!deps.connectionManager.pong(body)) {
|
||||||
|
reply.code(404).send({ error: "Client connection not found" })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
reply.code(204).send()
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { FastifyInstance } from "fastify"
|
import { FastifyInstance } from "fastify"
|
||||||
import { ServerMeta } from "../../api-types"
|
import { ServerMeta } from "../../api-types"
|
||||||
import { resolveNetworkAddresses } from "../network-addresses"
|
|
||||||
|
|
||||||
interface RouteDeps {
|
interface RouteDeps {
|
||||||
serverMeta: ServerMeta
|
serverMeta: ServerMeta
|
||||||
@@ -13,14 +13,12 @@ export function registerMetaRoutes(app: FastifyInstance, deps: RouteDeps) {
|
|||||||
function buildMetaResponse(meta: ServerMeta): ServerMeta {
|
function buildMetaResponse(meta: ServerMeta): ServerMeta {
|
||||||
const localPort = resolveLocalPort(meta)
|
const localPort = resolveLocalPort(meta)
|
||||||
const remote = resolveRemote(meta)
|
const remote = resolveRemote(meta)
|
||||||
const addresses = remote && remote.port > 0 ? resolveNetworkAddresses({ host: meta.host, protocol: remote.protocol, port: remote.port }) : []
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...meta,
|
...meta,
|
||||||
localPort,
|
localPort,
|
||||||
remotePort: remote?.port,
|
remotePort: remote?.port,
|
||||||
listeningMode: meta.host === "0.0.0.0" || !isLoopbackHost(meta.host) ? "all" : "local",
|
listeningMode: meta.host === "0.0.0.0" || !isLoopbackHost(meta.host) ? "all" : "local",
|
||||||
addresses,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -6,12 +6,14 @@ import type { EventBus } from "../../events/bus"
|
|||||||
import type { Logger } from "../../logger"
|
import type { Logger } from "../../logger"
|
||||||
import { PluginChannelManager } from "../../plugins/channel"
|
import { PluginChannelManager } from "../../plugins/channel"
|
||||||
import { buildPingEvent, handlePluginEvent } from "../../plugins/handlers"
|
import { buildPingEvent, handlePluginEvent } from "../../plugins/handlers"
|
||||||
|
import { VoiceModeManager } from "../../plugins/voice-mode"
|
||||||
|
|
||||||
interface RouteDeps {
|
interface RouteDeps {
|
||||||
workspaceManager: WorkspaceManager
|
workspaceManager: WorkspaceManager
|
||||||
eventBus: EventBus
|
eventBus: EventBus
|
||||||
logger: Logger
|
logger: Logger
|
||||||
channel: PluginChannelManager
|
channel: PluginChannelManager
|
||||||
|
voiceModeManager: VoiceModeManager
|
||||||
}
|
}
|
||||||
|
|
||||||
const PluginEventSchema = z.object({
|
const PluginEventSchema = z.object({
|
||||||
@@ -21,6 +23,8 @@ const PluginEventSchema = z.object({
|
|||||||
|
|
||||||
const VoiceModeStateSchema = z.object({
|
const VoiceModeStateSchema = z.object({
|
||||||
enabled: z.boolean(),
|
enabled: z.boolean(),
|
||||||
|
clientId: z.string().trim().min(1),
|
||||||
|
connectionId: z.string().trim().min(1),
|
||||||
})
|
})
|
||||||
|
|
||||||
export function registerPluginRoutes(app: FastifyInstance, deps: RouteDeps) {
|
export function registerPluginRoutes(app: FastifyInstance, deps: RouteDeps) {
|
||||||
@@ -38,6 +42,7 @@ export function registerPluginRoutes(app: FastifyInstance, deps: RouteDeps) {
|
|||||||
reply.hijack()
|
reply.hijack()
|
||||||
|
|
||||||
const registration = deps.channel.register(request.params.id, reply)
|
const registration = deps.channel.register(request.params.id, reply)
|
||||||
|
deps.voiceModeManager.syncInstance(request.params.id)
|
||||||
|
|
||||||
const heartbeat = setInterval(() => {
|
const heartbeat = setInterval(() => {
|
||||||
deps.channel.send(request.params.id, buildPingEvent())
|
deps.channel.send(request.params.id, buildPingEvent())
|
||||||
@@ -61,13 +66,11 @@ export function registerPluginRoutes(app: FastifyInstance, deps: RouteDeps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const payload = VoiceModeStateSchema.parse(request.body ?? {})
|
const payload = VoiceModeStateSchema.parse(request.body ?? {})
|
||||||
deps.channel.send(request.params.id, {
|
deps.voiceModeManager.setEnabled(
|
||||||
type: "codenomad.voiceMode",
|
request.params.id,
|
||||||
properties: {
|
{ clientId: payload.clientId, connectionId: payload.connectionId },
|
||||||
enabled: payload.enabled,
|
payload.enabled,
|
||||||
formatVersion: "v1",
|
)
|
||||||
},
|
|
||||||
})
|
|
||||||
return { enabled: payload.enabled }
|
return { enabled: payload.enabled }
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
166
packages/server/src/server/routes/remote-servers.ts
Normal file
166
packages/server/src/server/routes/remote-servers.ts
Normal file
@@ -0,0 +1,166 @@
|
|||||||
|
import { Agent, fetch } from "undici"
|
||||||
|
import type { FastifyInstance } from "fastify"
|
||||||
|
import { z } from "zod"
|
||||||
|
import type { Logger } from "../../logger"
|
||||||
|
import type { RemoteServerProbeResponse } from "../../api-types"
|
||||||
|
|
||||||
|
interface RouteDeps {
|
||||||
|
logger: Logger
|
||||||
|
}
|
||||||
|
|
||||||
|
const ProbeSchema = z.object({
|
||||||
|
baseUrl: z.string().min(1),
|
||||||
|
skipTlsVerify: z.boolean().optional(),
|
||||||
|
})
|
||||||
|
|
||||||
|
const PROBE_TIMEOUT_MS = 8_000
|
||||||
|
|
||||||
|
export function registerRemoteServerRoutes(app: FastifyInstance, deps: RouteDeps) {
|
||||||
|
app.post("/api/remote-servers/probe", async (request, reply) => {
|
||||||
|
try {
|
||||||
|
const body = ProbeSchema.parse(request.body ?? {})
|
||||||
|
return await probeRemoteServer(body.baseUrl, Boolean(body.skipTlsVerify))
|
||||||
|
} catch (error) {
|
||||||
|
deps.logger.warn({ err: error }, "Failed to probe remote server")
|
||||||
|
reply.code(400)
|
||||||
|
return { error: error instanceof Error ? error.message : "Invalid request" }
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async function probeRemoteServer(baseUrl: string, skipTlsVerify: boolean): Promise<RemoteServerProbeResponse> {
|
||||||
|
const normalizedUrl = normalizeBaseUrl(baseUrl)
|
||||||
|
const probeUrl = new URL("./api/auth/status", `${normalizedUrl}/`)
|
||||||
|
const controller = new AbortController()
|
||||||
|
const timeout = setTimeout(() => controller.abort(), PROBE_TIMEOUT_MS)
|
||||||
|
const dispatcher = skipTlsVerify ? new Agent({ connect: { rejectUnauthorized: false } }) : undefined
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(probeUrl, {
|
||||||
|
method: "GET",
|
||||||
|
dispatcher,
|
||||||
|
signal: controller.signal,
|
||||||
|
headers: {
|
||||||
|
Accept: "application/json",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
reachable: true,
|
||||||
|
normalizedUrl,
|
||||||
|
skipTlsVerify,
|
||||||
|
requiresAuth: false,
|
||||||
|
authenticated: false,
|
||||||
|
error: `Remote server returned HTTP ${response.status}`,
|
||||||
|
errorCode: "http_error",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload = (await response.json()) as { authenticated?: unknown }
|
||||||
|
if (typeof payload?.authenticated !== "boolean") {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
reachable: true,
|
||||||
|
normalizedUrl,
|
||||||
|
skipTlsVerify,
|
||||||
|
requiresAuth: false,
|
||||||
|
authenticated: false,
|
||||||
|
error: "Remote server did not return a valid CodeNomad auth response",
|
||||||
|
errorCode: "invalid_server",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
reachable: true,
|
||||||
|
normalizedUrl,
|
||||||
|
skipTlsVerify,
|
||||||
|
requiresAuth: !payload.authenticated,
|
||||||
|
authenticated: payload.authenticated,
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
const message = describeProbeError(error)
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
reachable: false,
|
||||||
|
normalizedUrl,
|
||||||
|
skipTlsVerify,
|
||||||
|
requiresAuth: false,
|
||||||
|
authenticated: false,
|
||||||
|
error: message.message,
|
||||||
|
errorCode: message.code,
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
clearTimeout(timeout)
|
||||||
|
await dispatcher?.close().catch(() => {})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeBaseUrl(input: string): string {
|
||||||
|
const parsed = new URL(input.trim())
|
||||||
|
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
|
||||||
|
throw new Error("Server URL must use http:// or https://")
|
||||||
|
}
|
||||||
|
|
||||||
|
parsed.hash = ""
|
||||||
|
parsed.search = ""
|
||||||
|
parsed.pathname = parsed.pathname === "/" ? "/" : parsed.pathname.replace(/\/+$/, "") || "/"
|
||||||
|
const value = parsed.toString()
|
||||||
|
return parsed.pathname === "/" ? value.replace(/\/$/, "") : value.replace(/\/$/, "")
|
||||||
|
}
|
||||||
|
|
||||||
|
function describeProbeError(error: unknown): { code: string; message: string } {
|
||||||
|
const chain = unwrapErrorChain(error)
|
||||||
|
const detailed =
|
||||||
|
chain.find((entry) => {
|
||||||
|
const code = (entry?.code ?? "").toString()
|
||||||
|
return Boolean(code) && code !== "UND_ERR_RESPONSE_STATUS_CODE"
|
||||||
|
}) ?? chain[0]
|
||||||
|
|
||||||
|
const code = (detailed?.code ?? "").toString()
|
||||||
|
const exactMessage = detailed?.message?.trim() || chain.find((entry) => entry.message?.trim())?.message?.trim()
|
||||||
|
|
||||||
|
if (code === "DEPTH_ZERO_SELF_SIGNED_CERT" || code === "SELF_SIGNED_CERT_IN_CHAIN" || code === "CERT_HAS_EXPIRED") {
|
||||||
|
return {
|
||||||
|
code: "tls_error",
|
||||||
|
message: "Certificate check failed while connecting to the remote server.",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
code:
|
||||||
|
code === "ERR_INVALID_URL"
|
||||||
|
? "invalid_url"
|
||||||
|
: code === "ECONNREFUSED"
|
||||||
|
? "connection_refused"
|
||||||
|
: code === "ENOTFOUND"
|
||||||
|
? "dns_error"
|
||||||
|
: code === "UND_ERR_CONNECT_TIMEOUT" || code === "ABORT_ERR"
|
||||||
|
? "timeout"
|
||||||
|
: code
|
||||||
|
? code.toLowerCase()
|
||||||
|
: "probe_failed",
|
||||||
|
message: exactMessage || "Failed to connect to the remote server.",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function unwrapErrorChain(error: unknown): Array<{ code?: unknown; message?: string }> {
|
||||||
|
const results: Array<{ code?: unknown; message?: string }> = []
|
||||||
|
let current: unknown = error
|
||||||
|
const seen = new Set<unknown>()
|
||||||
|
|
||||||
|
while (current && typeof current === "object" && !seen.has(current)) {
|
||||||
|
seen.add(current)
|
||||||
|
const entry = current as { code?: unknown; message?: string; cause?: unknown }
|
||||||
|
results.push({ code: entry.code, message: entry.message })
|
||||||
|
current = entry.cause
|
||||||
|
}
|
||||||
|
|
||||||
|
if (results.length === 0 && error instanceof Error) {
|
||||||
|
results.push({ message: error.message })
|
||||||
|
}
|
||||||
|
|
||||||
|
return results
|
||||||
|
}
|
||||||
56
packages/server/src/server/routes/sidecars.ts
Normal file
56
packages/server/src/server/routes/sidecars.ts
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
import { FastifyInstance } from "fastify"
|
||||||
|
import { z } from "zod"
|
||||||
|
import type { SideCarManager } from "../../sidecars/manager"
|
||||||
|
|
||||||
|
interface RouteDeps {
|
||||||
|
sidecarManager: SideCarManager
|
||||||
|
}
|
||||||
|
|
||||||
|
const SideCarCreateSchema = z.object({
|
||||||
|
kind: z.literal("port").default("port"),
|
||||||
|
name: z.string().trim().min(1),
|
||||||
|
port: z.number().int().min(1).max(65535),
|
||||||
|
insecure: z.boolean().default(false),
|
||||||
|
prefixMode: z.enum(["strip", "preserve"]).default("strip"),
|
||||||
|
})
|
||||||
|
|
||||||
|
const SideCarUpdateSchema = SideCarCreateSchema.omit({ kind: true }).partial().refine((value) => Object.keys(value).length > 0, {
|
||||||
|
message: "At least one field is required",
|
||||||
|
})
|
||||||
|
|
||||||
|
export function registerSideCarRoutes(app: FastifyInstance, deps: RouteDeps) {
|
||||||
|
app.get("/api/sidecars", async () => {
|
||||||
|
return { sidecars: await deps.sidecarManager.list() }
|
||||||
|
})
|
||||||
|
|
||||||
|
app.post("/api/sidecars", async (request, reply) => {
|
||||||
|
try {
|
||||||
|
const body = SideCarCreateSchema.parse(request.body ?? {})
|
||||||
|
const sidecar = await deps.sidecarManager.create(body)
|
||||||
|
reply.code(201)
|
||||||
|
return sidecar
|
||||||
|
} catch (error) {
|
||||||
|
reply.code(400)
|
||||||
|
return { error: error instanceof Error ? error.message : "Failed to create SideCar" }
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
app.put<{ Params: { id: string } }>("/api/sidecars/:id", async (request, reply) => {
|
||||||
|
try {
|
||||||
|
const body = SideCarUpdateSchema.parse(request.body ?? {})
|
||||||
|
return await deps.sidecarManager.update(request.params.id, body)
|
||||||
|
} catch (error) {
|
||||||
|
reply.code(400)
|
||||||
|
return { error: error instanceof Error ? error.message : "Failed to update SideCar" }
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
app.delete<{ Params: { id: string } }>("/api/sidecars/:id", async (request, reply) => {
|
||||||
|
const removed = await deps.sidecarManager.delete(request.params.id)
|
||||||
|
if (!removed) {
|
||||||
|
reply.code(404)
|
||||||
|
return { error: "SideCar not found" }
|
||||||
|
}
|
||||||
|
reply.code(204)
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -107,6 +107,10 @@ function mapLegacyToOwnerDocs(legacyConfig: unknown, legacyState: unknown): { co
|
|||||||
if (typeof listeningMode === "string") {
|
if (typeof listeningMode === "string") {
|
||||||
serverConfig.listeningMode = listeningMode
|
serverConfig.listeningMode = listeningMode
|
||||||
}
|
}
|
||||||
|
const logLevel = preferences.logLevel
|
||||||
|
if (typeof logLevel === "string") {
|
||||||
|
serverConfig.logLevel = logLevel
|
||||||
|
}
|
||||||
const lastUsedBinary = preferences.lastUsedBinary
|
const lastUsedBinary = preferences.lastUsedBinary
|
||||||
if (typeof lastUsedBinary === "string") {
|
if (typeof lastUsedBinary === "string") {
|
||||||
serverConfig.opencodeBinary = lastUsedBinary
|
serverConfig.opencodeBinary = lastUsedBinary
|
||||||
@@ -135,6 +139,7 @@ function mapLegacyToOwnerDocs(legacyConfig: unknown, legacyState: unknown): { co
|
|||||||
const moved = new Set([
|
const moved = new Set([
|
||||||
"environmentVariables",
|
"environmentVariables",
|
||||||
"listeningMode",
|
"listeningMode",
|
||||||
|
"logLevel",
|
||||||
"lastUsedBinary",
|
"lastUsedBinary",
|
||||||
"modelRecents",
|
"modelRecents",
|
||||||
"modelFavorites",
|
"modelFavorites",
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import type { Logger } from "../logger"
|
import type { Logger } from "../logger"
|
||||||
import type { EventBus } from "../events/bus"
|
import type { EventBus } from "../events/bus"
|
||||||
import type { ConfigLocation } from "../config/location"
|
import type { ConfigLocation } from "../config/location"
|
||||||
|
import { z } from "zod"
|
||||||
import { YamlDocStore, type SettingsDoc } from "./yaml-doc-store"
|
import { YamlDocStore, type SettingsDoc } from "./yaml-doc-store"
|
||||||
import { migrateSettingsLayout } from "./migrate"
|
import { migrateSettingsLayout } from "./migrate"
|
||||||
import type { WorkspaceEventPayload } from "../api-types"
|
import type { WorkspaceEventPayload } from "../api-types"
|
||||||
@@ -8,6 +9,54 @@ import { sanitizeConfigOwner } from "./public-config"
|
|||||||
|
|
||||||
export type DocKind = "config" | "state"
|
export type DocKind = "config" | "state"
|
||||||
|
|
||||||
|
const CanonicalLogLevelSchema = z.preprocess(
|
||||||
|
(value) => (typeof value === "string" ? value.trim().toUpperCase() : value),
|
||||||
|
z.enum(["DEBUG", "INFO", "WARN", "ERROR"]),
|
||||||
|
)
|
||||||
|
|
||||||
|
function isPlainObject(value: unknown): value is Record<string, unknown> {
|
||||||
|
return typeof value === "object" && value !== null && !Array.isArray(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
function isDeepEqual(a: unknown, b: unknown): boolean {
|
||||||
|
if (a === b) return true
|
||||||
|
try {
|
||||||
|
return JSON.stringify(a) === JSON.stringify(b)
|
||||||
|
} catch {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeServerConfigOwner(value: SettingsDoc): SettingsDoc {
|
||||||
|
if (!isPlainObject(value)) {
|
||||||
|
return {}
|
||||||
|
}
|
||||||
|
|
||||||
|
const next: SettingsDoc = { ...value }
|
||||||
|
const parsedLogLevel = CanonicalLogLevelSchema.safeParse(next.logLevel)
|
||||||
|
if (parsedLogLevel.success) {
|
||||||
|
next.logLevel = parsedLogLevel.data
|
||||||
|
} else if (next.logLevel !== undefined) {
|
||||||
|
next.logLevel = "DEBUG"
|
||||||
|
}
|
||||||
|
return next
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeConfigDoc(doc: SettingsDoc): SettingsDoc {
|
||||||
|
if (!isPlainObject(doc)) {
|
||||||
|
return {}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isPlainObject(doc.server)) {
|
||||||
|
return doc
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...doc,
|
||||||
|
server: normalizeServerConfigOwner(doc.server as SettingsDoc),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export class SettingsService {
|
export class SettingsService {
|
||||||
private readonly configStore: YamlDocStore
|
private readonly configStore: YamlDocStore
|
||||||
private readonly stateStore: YamlDocStore
|
private readonly stateStore: YamlDocStore
|
||||||
@@ -23,22 +72,44 @@ export class SettingsService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
getDoc(kind: DocKind): SettingsDoc {
|
getDoc(kind: DocKind): SettingsDoc {
|
||||||
return kind === "config" ? this.configStore.get() : this.stateStore.get()
|
if (kind !== "config") {
|
||||||
|
return this.stateStore.get()
|
||||||
|
}
|
||||||
|
|
||||||
|
const current = this.configStore.get()
|
||||||
|
const normalized = normalizeConfigDoc(current)
|
||||||
|
if (!isDeepEqual(current, normalized)) {
|
||||||
|
this.configStore.replace(normalized)
|
||||||
|
}
|
||||||
|
return normalized
|
||||||
}
|
}
|
||||||
|
|
||||||
mergePatchDoc(kind: DocKind, patch: unknown): SettingsDoc {
|
mergePatchDoc(kind: DocKind, patch: unknown): SettingsDoc {
|
||||||
const updated = kind === "config" ? this.configStore.mergePatch(patch) : this.stateStore.mergePatch(patch)
|
const updated =
|
||||||
|
kind === "config"
|
||||||
|
? this.configStore.replace(normalizeConfigDoc(this.configStore.mergePatch(patch)))
|
||||||
|
: this.stateStore.mergePatch(patch)
|
||||||
this.publish(kind, "*")
|
this.publish(kind, "*")
|
||||||
return updated
|
return updated
|
||||||
}
|
}
|
||||||
|
|
||||||
getOwner(kind: DocKind, owner: string): SettingsDoc {
|
getOwner(kind: DocKind, owner: string): SettingsDoc {
|
||||||
return kind === "config" ? this.configStore.getOwner(owner) : this.stateStore.getOwner(owner)
|
if (kind !== "config") {
|
||||||
|
return this.stateStore.getOwner(owner)
|
||||||
|
}
|
||||||
|
|
||||||
|
return owner === "server"
|
||||||
|
? normalizeServerConfigOwner(this.getDoc("config").server as SettingsDoc)
|
||||||
|
: this.getDoc("config")[owner] as SettingsDoc
|
||||||
}
|
}
|
||||||
|
|
||||||
mergePatchOwner(kind: DocKind, owner: string, patch: unknown): SettingsDoc {
|
mergePatchOwner(kind: DocKind, owner: string, patch: unknown): SettingsDoc {
|
||||||
const updated =
|
const updated =
|
||||||
kind === "config" ? this.configStore.mergePatchOwner(owner, patch) : this.stateStore.mergePatchOwner(owner, patch)
|
kind === "config"
|
||||||
|
? owner === "server"
|
||||||
|
? this.configStore.replaceOwner(owner, normalizeServerConfigOwner(this.configStore.mergePatchOwner(owner, patch)))
|
||||||
|
: this.configStore.mergePatchOwner(owner, patch)
|
||||||
|
: this.stateStore.mergePatchOwner(owner, patch)
|
||||||
this.publish(kind, owner, updated)
|
this.publish(kind, owner, updated)
|
||||||
return updated
|
return updated
|
||||||
}
|
}
|
||||||
|
|||||||
256
packages/server/src/sidecars/manager.ts
Normal file
256
packages/server/src/sidecars/manager.ts
Normal file
@@ -0,0 +1,256 @@
|
|||||||
|
import { connect } from "net"
|
||||||
|
import type { EventBus } from "../events/bus"
|
||||||
|
import type { Logger } from "../logger"
|
||||||
|
import type { SettingsService } from "../settings/service"
|
||||||
|
import type { SideCar, SideCarKind, SideCarPrefixMode, SideCarStatus } from "../api-types"
|
||||||
|
|
||||||
|
interface SideCarManagerOptions {
|
||||||
|
settings: SettingsService
|
||||||
|
eventBus: EventBus
|
||||||
|
logger: Logger
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SideCarConfigRecord {
|
||||||
|
id: string
|
||||||
|
kind: SideCarKind
|
||||||
|
name: string
|
||||||
|
port: number
|
||||||
|
insecure: boolean
|
||||||
|
prefixMode: SideCarPrefixMode
|
||||||
|
createdAt: string
|
||||||
|
updatedAt: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SideCarRuntimeRecord {
|
||||||
|
status: SideCarStatus
|
||||||
|
}
|
||||||
|
|
||||||
|
export class SideCarManager {
|
||||||
|
private readonly configs = new Map<string, SideCarConfigRecord>()
|
||||||
|
private readonly runtime = new Map<string, SideCarRuntimeRecord>()
|
||||||
|
|
||||||
|
constructor(private readonly options: SideCarManagerOptions) {
|
||||||
|
for (const record of this.loadConfiguredSideCars()) {
|
||||||
|
this.configs.set(record.id, record)
|
||||||
|
this.runtime.set(record.id, { status: "stopped" })
|
||||||
|
}
|
||||||
|
|
||||||
|
queueMicrotask(() => {
|
||||||
|
for (const record of this.configs.values()) {
|
||||||
|
void this.refreshPortSideCar(record.id).catch((error) => {
|
||||||
|
this.options.logger.warn({ sidecarId: record.id, err: error }, "Failed to probe sidecar port")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async list(): Promise<SideCar[]> {
|
||||||
|
await this.refreshPortStatuses()
|
||||||
|
return Array.from(this.configs.values()).map((record) => this.toSideCar(record))
|
||||||
|
}
|
||||||
|
|
||||||
|
async get(id: string): Promise<SideCar | undefined> {
|
||||||
|
if (!this.configs.has(id)) return undefined
|
||||||
|
await this.refreshPortSideCar(id)
|
||||||
|
return this.toSideCar(this.requireConfig(id))
|
||||||
|
}
|
||||||
|
|
||||||
|
async create(input: {
|
||||||
|
kind: SideCarKind
|
||||||
|
name: string
|
||||||
|
port: number
|
||||||
|
insecure: boolean
|
||||||
|
prefixMode: SideCarPrefixMode
|
||||||
|
}): Promise<SideCar> {
|
||||||
|
const normalizedName = input.name.trim()
|
||||||
|
const id = this.buildSideCarId(normalizedName)
|
||||||
|
if (this.configs.has(id)) {
|
||||||
|
throw new Error(`SideCar '${id}' already exists`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const now = new Date().toISOString()
|
||||||
|
const record: SideCarConfigRecord = {
|
||||||
|
id,
|
||||||
|
kind: input.kind,
|
||||||
|
name: normalizedName,
|
||||||
|
port: input.port,
|
||||||
|
insecure: input.insecure,
|
||||||
|
prefixMode: input.prefixMode,
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
}
|
||||||
|
|
||||||
|
this.configs.set(record.id, record)
|
||||||
|
this.runtime.set(record.id, { status: "stopped" })
|
||||||
|
this.persistConfigs()
|
||||||
|
await this.refreshPortSideCar(record.id)
|
||||||
|
return this.toSideCar(record)
|
||||||
|
}
|
||||||
|
|
||||||
|
async update(
|
||||||
|
id: string,
|
||||||
|
input: Partial<{
|
||||||
|
name: string
|
||||||
|
port: number
|
||||||
|
insecure: boolean
|
||||||
|
prefixMode: SideCarPrefixMode
|
||||||
|
}>,
|
||||||
|
): Promise<SideCar> {
|
||||||
|
const record = this.requireConfig(id)
|
||||||
|
|
||||||
|
record.name = typeof input.name === "string" ? input.name.trim() : record.name
|
||||||
|
record.port = typeof input.port === "number" ? input.port : record.port
|
||||||
|
record.insecure = typeof input.insecure === "boolean" ? input.insecure : record.insecure
|
||||||
|
record.prefixMode = typeof input.prefixMode === "string" ? input.prefixMode : record.prefixMode
|
||||||
|
record.updatedAt = new Date().toISOString()
|
||||||
|
|
||||||
|
this.persistConfigs()
|
||||||
|
await this.refreshPortSideCar(id)
|
||||||
|
return this.toSideCar(record)
|
||||||
|
}
|
||||||
|
|
||||||
|
async delete(id: string): Promise<boolean> {
|
||||||
|
const record = this.configs.get(id)
|
||||||
|
if (!record) return false
|
||||||
|
|
||||||
|
this.configs.delete(id)
|
||||||
|
this.runtime.delete(id)
|
||||||
|
this.persistConfigs()
|
||||||
|
this.options.eventBus.publish({ type: "sidecar.removed", sidecarId: id })
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
async shutdown() {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
buildTargetOrigin(sidecar: Pick<SideCar, "port" | "insecure">): string {
|
||||||
|
const protocol = sidecar.insecure ? "http" : "https"
|
||||||
|
return `${protocol}://127.0.0.1:${sidecar.port}`
|
||||||
|
}
|
||||||
|
|
||||||
|
buildProxyBasePath(id: string): string {
|
||||||
|
return `/sidecars/${encodeURIComponent(id)}`
|
||||||
|
}
|
||||||
|
|
||||||
|
buildTargetPath(id: string, incomingPath: string, search = ""): string {
|
||||||
|
const record = this.requireConfig(id)
|
||||||
|
const publicBase = this.buildProxyBasePath(id)
|
||||||
|
const normalizedPath = incomingPath || publicBase
|
||||||
|
|
||||||
|
if (record.prefixMode === "preserve") {
|
||||||
|
return `${normalizedPath}${search}`
|
||||||
|
}
|
||||||
|
|
||||||
|
let stripped = normalizedPath.startsWith(publicBase) ? normalizedPath.slice(publicBase.length) : normalizedPath
|
||||||
|
if (!stripped || stripped === "/") {
|
||||||
|
stripped = "/"
|
||||||
|
} else if (!stripped.startsWith("/")) {
|
||||||
|
stripped = `/${stripped}`
|
||||||
|
}
|
||||||
|
return `${stripped}${search}`
|
||||||
|
}
|
||||||
|
|
||||||
|
private async refreshPortStatuses() {
|
||||||
|
await Promise.all(Array.from(this.configs.values()).map((record) => this.refreshPortSideCar(record.id)))
|
||||||
|
}
|
||||||
|
|
||||||
|
private async refreshPortSideCar(id: string) {
|
||||||
|
const record = this.configs.get(id)
|
||||||
|
if (!record) return
|
||||||
|
const isAvailable = await this.isPortAvailable(record.port)
|
||||||
|
const current = this.runtime.get(id)
|
||||||
|
const nextStatus: SideCarStatus = isAvailable ? "running" : "stopped"
|
||||||
|
if (current?.status === nextStatus) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
this.runtime.set(id, { status: nextStatus })
|
||||||
|
record.updatedAt = new Date().toISOString()
|
||||||
|
this.publish(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
private publish(id: string) {
|
||||||
|
const record = this.configs.get(id)
|
||||||
|
if (!record) return
|
||||||
|
this.options.eventBus.publish({ type: "sidecar.updated", sidecar: this.toSideCar(record) })
|
||||||
|
}
|
||||||
|
|
||||||
|
private toSideCar(record: SideCarConfigRecord): SideCar {
|
||||||
|
const runtime = this.runtime.get(record.id)
|
||||||
|
return {
|
||||||
|
id: record.id,
|
||||||
|
kind: record.kind,
|
||||||
|
name: record.name,
|
||||||
|
port: record.port,
|
||||||
|
insecure: record.insecure,
|
||||||
|
prefixMode: record.prefixMode,
|
||||||
|
status: runtime?.status ?? "stopped",
|
||||||
|
createdAt: record.createdAt,
|
||||||
|
updatedAt: record.updatedAt,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private requireConfig(id: string): SideCarConfigRecord {
|
||||||
|
const record = this.configs.get(id)
|
||||||
|
if (!record) {
|
||||||
|
throw new Error("SideCar not found")
|
||||||
|
}
|
||||||
|
return record
|
||||||
|
}
|
||||||
|
|
||||||
|
private persistConfigs() {
|
||||||
|
const sidecars = Array.from(this.configs.values()).map((record) => ({ ...record }))
|
||||||
|
this.options.settings.mergePatchOwner("config", "server", { sidecars })
|
||||||
|
}
|
||||||
|
|
||||||
|
private loadConfiguredSideCars(): SideCarConfigRecord[] {
|
||||||
|
const serverConfig = this.options.settings.getOwner("config", "server") as { sidecars?: unknown }
|
||||||
|
const list = Array.isArray(serverConfig?.sidecars) ? serverConfig.sidecars : []
|
||||||
|
const records: SideCarConfigRecord[] = []
|
||||||
|
for (const item of list) {
|
||||||
|
if (!item || typeof item !== "object") continue
|
||||||
|
const record = item as Record<string, unknown>
|
||||||
|
const kind = record.kind === "port" ? "port" : null
|
||||||
|
const id = typeof record.id === "string" && record.id.trim() ? record.id.trim() : null
|
||||||
|
const name = typeof record.name === "string" && record.name.trim() ? record.name.trim() : null
|
||||||
|
const port = typeof record.port === "number" && Number.isInteger(record.port) ? record.port : null
|
||||||
|
if (!kind || !id || !name || !port) continue
|
||||||
|
|
||||||
|
const insecure = record.insecure === true
|
||||||
|
const prefixMode = record.prefixMode === "preserve" ? "preserve" : "strip"
|
||||||
|
const createdAt = typeof record.createdAt === "string" && record.createdAt ? record.createdAt : new Date().toISOString()
|
||||||
|
const updatedAt = typeof record.updatedAt === "string" && record.updatedAt ? record.updatedAt : createdAt
|
||||||
|
records.push({ id, kind, name, port, insecure, prefixMode, createdAt, updatedAt })
|
||||||
|
}
|
||||||
|
return records
|
||||||
|
}
|
||||||
|
|
||||||
|
private isPortAvailable(port: number): Promise<boolean> {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
const socket = connect({ port, host: "127.0.0.1" }, () => {
|
||||||
|
socket.end()
|
||||||
|
resolve(true)
|
||||||
|
})
|
||||||
|
socket.once("error", () => {
|
||||||
|
socket.destroy()
|
||||||
|
resolve(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
private buildSideCarId(name: string): string {
|
||||||
|
const normalized = name
|
||||||
|
.trim()
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/[^a-z0-9]+/g, "-")
|
||||||
|
.replace(/-{2,}/g, "-")
|
||||||
|
.replace(/^-|-$/g, "")
|
||||||
|
|
||||||
|
if (!normalized) {
|
||||||
|
throw new Error("SideCar name must include letters or numbers")
|
||||||
|
}
|
||||||
|
|
||||||
|
return normalized
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -142,12 +142,15 @@ export class WorkspaceManager {
|
|||||||
[OPENCODE_SERVER_PASSWORD_ENV]: opencodePassword,
|
[OPENCODE_SERVER_PASSWORD_ENV]: opencodePassword,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const logLevel = (serverConfig as any)?.logLevel
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const { pid, port, exitPromise, getLastOutput } = await this.runtime.launch({
|
const { pid, port, exitPromise, getLastOutput } = await this.runtime.launch({
|
||||||
workspaceId: id,
|
workspaceId: id,
|
||||||
folder: workspacePath,
|
folder: workspacePath,
|
||||||
binaryPath: resolvedBinaryPath,
|
binaryPath: resolvedBinaryPath,
|
||||||
environment,
|
environment,
|
||||||
|
logLevel,
|
||||||
onExit: (info) => this.handleProcessExit(info.workspaceId, info),
|
onExit: (info) => this.handleProcessExit(info.workspaceId, info),
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -116,6 +116,7 @@ interface LaunchOptions {
|
|||||||
folder: string
|
folder: string
|
||||||
binaryPath: string
|
binaryPath: string
|
||||||
environment?: Record<string, string>
|
environment?: Record<string, string>
|
||||||
|
logLevel?: string
|
||||||
onExit?: (info: ProcessExitInfo) => void
|
onExit?: (info: ProcessExitInfo) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -139,7 +140,8 @@ export class WorkspaceRuntime {
|
|||||||
async launch(options: LaunchOptions): Promise<{ pid: number; port: number; exitPromise: Promise<ProcessExitInfo>; getLastOutput: () => string }> {
|
async launch(options: LaunchOptions): Promise<{ pid: number; port: number; exitPromise: Promise<ProcessExitInfo>; getLastOutput: () => string }> {
|
||||||
this.validateFolder(options.folder)
|
this.validateFolder(options.folder)
|
||||||
|
|
||||||
const args = ["serve", "--port", "0", "--print-logs", "--log-level", "DEBUG"]
|
const logLevel = typeof options.logLevel === "string" ? options.logLevel.toUpperCase() : "DEBUG"
|
||||||
|
const args = ["serve", "--port", "0", "--print-logs", "--log-level", logLevel]
|
||||||
const env = { ...process.env, ...(options.environment ?? {}) }
|
const env = { ...process.env, ...(options.environment ?? {}) }
|
||||||
|
|
||||||
let exitResolve: ((info: ProcessExitInfo) => void) | null = null
|
let exitResolve: ((info: ProcessExitInfo) => void) | null = null
|
||||||
|
|||||||
2
packages/tauri-app/Cargo.lock
generated
2
packages/tauri-app/Cargo.lock
generated
@@ -458,7 +458,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "codenomad-tauri"
|
name = "codenomad-tauri"
|
||||||
version = "0.12.3"
|
version = "0.13.3"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"dirs 5.0.1",
|
"dirs 5.0.1",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@codenomad/tauri-app",
|
"name": "@codenomad/tauri-app",
|
||||||
"version": "0.13.1",
|
"version": "0.13.3",
|
||||||
"private": true,
|
"private": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "codenomad-tauri"
|
name = "codenomad-tauri"
|
||||||
version = "0.12.3"
|
version = "0.13.3"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
license = "MIT"
|
license = "MIT"
|
||||||
|
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
{"main-window-native-dialogs":{"identifier":"main-window-native-dialogs","description":"Grant the main window access to required core features and native dialog commands.","remote":{"urls":["http://127.0.0.1:*","http://localhost:*","http://tauri.localhost/*","https://tauri.localhost/*"]},"local":true,"windows":["main"],"permissions":["core:default","core:menu:default","dialog:allow-open","opener:allow-default-urls","opener:allow-open-url","notification:allow-is-permission-granted","notification:allow-request-permission","notification:allow-notify","notification:allow-show","core:webview:allow-set-webview-zoom"]}}
|
{"main-window-native-dialogs":{"identifier":"main-window-native-dialogs","description":"Grant the main window access to required core features and native dialog commands.","remote":{"urls":["http://127.0.0.1:*","http://localhost:*","http://tauri.localhost/*","https://tauri.localhost/*"]},"local":true,"windows":["main"],"permissions":["core:default","core:menu:default","dialog:allow-open","opener:allow-default-urls","opener:allow-open-url","notification:allow-is-permission-granted","notification:allow-request-permission","notification:allow-notify","notification:allow-show","core:webview:allow-set-webview-zoom"]}}
|
||||||
@@ -2378,6 +2378,72 @@
|
|||||||
"const": "dialog:deny-save",
|
"const": "dialog:deny-save",
|
||||||
"markdownDescription": "Denies the save command without any pre-configured scope."
|
"markdownDescription": "Denies the save command without any pre-configured scope."
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"description": "No features are enabled by default, as we believe\nthe shortcuts can be inherently dangerous and it is\napplication specific if specific shortcuts should be\nregistered or unregistered.\n",
|
||||||
|
"type": "string",
|
||||||
|
"const": "global-shortcut:default",
|
||||||
|
"markdownDescription": "No features are enabled by default, as we believe\nthe shortcuts can be inherently dangerous and it is\napplication specific if specific shortcuts should be\nregistered or unregistered.\n"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "Enables the is_registered command without any pre-configured scope.",
|
||||||
|
"type": "string",
|
||||||
|
"const": "global-shortcut:allow-is-registered",
|
||||||
|
"markdownDescription": "Enables the is_registered command without any pre-configured scope."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "Enables the register command without any pre-configured scope.",
|
||||||
|
"type": "string",
|
||||||
|
"const": "global-shortcut:allow-register",
|
||||||
|
"markdownDescription": "Enables the register command without any pre-configured scope."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "Enables the register_all command without any pre-configured scope.",
|
||||||
|
"type": "string",
|
||||||
|
"const": "global-shortcut:allow-register-all",
|
||||||
|
"markdownDescription": "Enables the register_all command without any pre-configured scope."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "Enables the unregister command without any pre-configured scope.",
|
||||||
|
"type": "string",
|
||||||
|
"const": "global-shortcut:allow-unregister",
|
||||||
|
"markdownDescription": "Enables the unregister command without any pre-configured scope."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "Enables the unregister_all command without any pre-configured scope.",
|
||||||
|
"type": "string",
|
||||||
|
"const": "global-shortcut:allow-unregister-all",
|
||||||
|
"markdownDescription": "Enables the unregister_all command without any pre-configured scope."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "Denies the is_registered command without any pre-configured scope.",
|
||||||
|
"type": "string",
|
||||||
|
"const": "global-shortcut:deny-is-registered",
|
||||||
|
"markdownDescription": "Denies the is_registered command without any pre-configured scope."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "Denies the register command without any pre-configured scope.",
|
||||||
|
"type": "string",
|
||||||
|
"const": "global-shortcut:deny-register",
|
||||||
|
"markdownDescription": "Denies the register command without any pre-configured scope."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "Denies the register_all command without any pre-configured scope.",
|
||||||
|
"type": "string",
|
||||||
|
"const": "global-shortcut:deny-register-all",
|
||||||
|
"markdownDescription": "Denies the register_all command without any pre-configured scope."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "Denies the unregister command without any pre-configured scope.",
|
||||||
|
"type": "string",
|
||||||
|
"const": "global-shortcut:deny-unregister",
|
||||||
|
"markdownDescription": "Denies the unregister command without any pre-configured scope."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "Denies the unregister_all command without any pre-configured scope.",
|
||||||
|
"type": "string",
|
||||||
|
"const": "global-shortcut:deny-unregister-all",
|
||||||
|
"markdownDescription": "Denies the unregister_all command without any pre-configured scope."
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"description": "This permission set configures which\nnotification features are by default exposed.\n\n#### Granted Permissions\n\nIt allows all notification related features.\n\n\n#### This default permission set includes:\n\n- `allow-is-permission-granted`\n- `allow-request-permission`\n- `allow-notify`\n- `allow-register-action-types`\n- `allow-register-listener`\n- `allow-cancel`\n- `allow-get-pending`\n- `allow-remove-active`\n- `allow-get-active`\n- `allow-check-permissions`\n- `allow-show`\n- `allow-batch`\n- `allow-list-channels`\n- `allow-delete-channel`\n- `allow-create-channel`\n- `allow-permission-state`",
|
"description": "This permission set configures which\nnotification features are by default exposed.\n\n#### Granted Permissions\n\nIt allows all notification related features.\n\n\n#### This default permission set includes:\n\n- `allow-is-permission-granted`\n- `allow-request-permission`\n- `allow-notify`\n- `allow-register-action-types`\n- `allow-register-listener`\n- `allow-cancel`\n- `allow-get-pending`\n- `allow-remove-active`\n- `allow-get-active`\n- `allow-check-permissions`\n- `allow-show`\n- `allow-batch`\n- `allow-list-channels`\n- `allow-delete-channel`\n- `allow-create-channel`\n- `allow-permission-state`",
|
||||||
"type": "string",
|
"type": "string",
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ use std::process::{Child, Command, Stdio};
|
|||||||
use std::sync::atomic::{AtomicBool, Ordering};
|
use std::sync::atomic::{AtomicBool, Ordering};
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use std::thread;
|
use std::thread;
|
||||||
use std::time::{Duration, Instant};
|
use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
|
||||||
use tauri::{webview::cookie::Cookie, AppHandle, Emitter, Manager, Url};
|
use tauri::{webview::cookie::Cookie, AppHandle, Emitter, Manager, Url};
|
||||||
|
|
||||||
#[cfg(windows)]
|
#[cfg(windows)]
|
||||||
@@ -48,7 +48,7 @@ fn workspace_root() -> Option<PathBuf> {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const SESSION_COOKIE_NAME: &str = "codenomad_session";
|
const SESSION_COOKIE_NAME_PREFIX: &str = "codenomad_session";
|
||||||
|
|
||||||
const CLI_STOP_GRACE_SECS: u64 = 30;
|
const CLI_STOP_GRACE_SECS: u64 = 30;
|
||||||
#[cfg(windows)]
|
#[cfg(windows)]
|
||||||
@@ -124,7 +124,11 @@ fn extract_cookie_value(set_cookie: &str, name: &str) -> Option<String> {
|
|||||||
Some(value.to_string())
|
Some(value.to_string())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn exchange_bootstrap_token(base_url: &str, token: &str) -> anyhow::Result<Option<String>> {
|
fn exchange_bootstrap_token(
|
||||||
|
base_url: &str,
|
||||||
|
token: &str,
|
||||||
|
cookie_name: &str,
|
||||||
|
) -> anyhow::Result<Option<String>> {
|
||||||
let parsed = Url::parse(base_url)?;
|
let parsed = Url::parse(base_url)?;
|
||||||
let host = parsed.host_str().unwrap_or("127.0.0.1");
|
let host = parsed.host_str().unwrap_or("127.0.0.1");
|
||||||
let port = parsed.port_or_known_default().unwrap_or(80);
|
let port = parsed.port_or_known_default().unwrap_or(80);
|
||||||
@@ -159,11 +163,11 @@ fn exchange_bootstrap_token(base_url: &str, token: &str) -> anyhow::Result<Optio
|
|||||||
for line in lines {
|
for line in lines {
|
||||||
// handle case-insensitive header name
|
// handle case-insensitive header name
|
||||||
if let Some(value) = line.strip_prefix("Set-Cookie:") {
|
if let Some(value) = line.strip_prefix("Set-Cookie:") {
|
||||||
if let Some(session_id) = extract_cookie_value(value.trim(), SESSION_COOKIE_NAME) {
|
if let Some(session_id) = extract_cookie_value(value.trim(), cookie_name) {
|
||||||
return Ok(Some(session_id));
|
return Ok(Some(session_id));
|
||||||
}
|
}
|
||||||
} else if let Some(value) = line.strip_prefix("set-cookie:") {
|
} else if let Some(value) = line.strip_prefix("set-cookie:") {
|
||||||
if let Some(session_id) = extract_cookie_value(value.trim(), SESSION_COOKIE_NAME) {
|
if let Some(session_id) = extract_cookie_value(value.trim(), cookie_name) {
|
||||||
return Ok(Some(session_id));
|
return Ok(Some(session_id));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -172,11 +176,16 @@ fn exchange_bootstrap_token(base_url: &str, token: &str) -> anyhow::Result<Optio
|
|||||||
Ok(None)
|
Ok(None)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn set_session_cookie(app: &AppHandle, base_url: &str, session_id: &str) -> anyhow::Result<()> {
|
fn set_session_cookie(
|
||||||
|
app: &AppHandle,
|
||||||
|
base_url: &str,
|
||||||
|
cookie_name: &str,
|
||||||
|
session_id: &str,
|
||||||
|
) -> anyhow::Result<()> {
|
||||||
let parsed = Url::parse(base_url)?;
|
let parsed = Url::parse(base_url)?;
|
||||||
let domain = parsed.host_str().unwrap_or("127.0.0.1").to_string();
|
let domain = parsed.host_str().unwrap_or("127.0.0.1").to_string();
|
||||||
|
|
||||||
let cookie = Cookie::build((SESSION_COOKIE_NAME, session_id))
|
let cookie = Cookie::build((cookie_name.to_string(), session_id.to_string()))
|
||||||
.domain(domain)
|
.domain(domain)
|
||||||
.path("/")
|
.path("/")
|
||||||
.http_only(true)
|
.http_only(true)
|
||||||
@@ -190,6 +199,16 @@ fn set_session_cookie(app: &AppHandle, base_url: &str, session_id: &str) -> anyh
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn generate_auth_cookie_name() -> String {
|
||||||
|
let pid = std::process::id();
|
||||||
|
let timestamp = SystemTime::now()
|
||||||
|
.duration_since(UNIX_EPOCH)
|
||||||
|
.map(|duration| duration.as_millis())
|
||||||
|
.unwrap_or(0);
|
||||||
|
|
||||||
|
format!("{SESSION_COOKIE_NAME_PREFIX}_{pid}_{timestamp}")
|
||||||
|
}
|
||||||
|
|
||||||
const DEFAULT_CONFIG_PATH: &str = "~/.config/codenomad/config.json";
|
const DEFAULT_CONFIG_PATH: &str = "~/.config/codenomad/config.json";
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
@@ -503,7 +522,8 @@ impl CliProcessManager {
|
|||||||
"resolved CLI entry runner={:?} entry={} host={}",
|
"resolved CLI entry runner={:?} entry={} host={}",
|
||||||
resolution.runner, resolution.entry, host
|
resolution.runner, resolution.entry, host
|
||||||
));
|
));
|
||||||
let args = resolution.build_args(dev, &host);
|
let auth_cookie_name = Arc::new(generate_auth_cookie_name());
|
||||||
|
let args = resolution.build_args(dev, &host, auth_cookie_name.as_str());
|
||||||
log_line(&format!("CLI args: {:?}", args));
|
log_line(&format!("CLI args: {:?}", args));
|
||||||
if dev {
|
if dev {
|
||||||
log_line("development mode: will prefer tsx + source if present");
|
log_line("development mode: will prefer tsx + source if present");
|
||||||
@@ -514,7 +534,9 @@ impl CliProcessManager {
|
|||||||
log_line(&format!("using cwd={}", c.display()));
|
log_line(&format!("using cwd={}", c.display()));
|
||||||
}
|
}
|
||||||
|
|
||||||
let command_info = if supports_user_shell() {
|
let use_user_shell = supports_user_shell();
|
||||||
|
|
||||||
|
let command_info = if use_user_shell {
|
||||||
log_line("spawning via user shell");
|
log_line("spawning via user shell");
|
||||||
ShellCommandType::UserShell(build_shell_command_string(&resolution, &args)?)
|
ShellCommandType::UserShell(build_shell_command_string(&resolution, &args)?)
|
||||||
} else {
|
} else {
|
||||||
@@ -525,7 +547,7 @@ impl CliProcessManager {
|
|||||||
})
|
})
|
||||||
};
|
};
|
||||||
|
|
||||||
if !supports_user_shell() {
|
if !use_user_shell {
|
||||||
if which::which(&resolution.node_binary).is_err() {
|
if which::which(&resolution.node_binary).is_err() {
|
||||||
return Err(anyhow::anyhow!(
|
return Err(anyhow::anyhow!(
|
||||||
"Node binary not found. Make sure Node.js is installed."
|
"Node binary not found. Make sure Node.js is installed."
|
||||||
@@ -539,6 +561,8 @@ impl CliProcessManager {
|
|||||||
let mut c = Command::new(&cmd.shell);
|
let mut c = Command::new(&cmd.shell);
|
||||||
c.args(&cmd.args)
|
c.args(&cmd.args)
|
||||||
.env("ELECTRON_RUN_AS_NODE", "1")
|
.env("ELECTRON_RUN_AS_NODE", "1")
|
||||||
|
.env_remove("npm_config_prefix")
|
||||||
|
.env_remove("NPM_CONFIG_PREFIX")
|
||||||
.stdout(Stdio::piped())
|
.stdout(Stdio::piped())
|
||||||
.stderr(Stdio::piped());
|
.stderr(Stdio::piped());
|
||||||
configure_spawn(&mut c);
|
configure_spawn(&mut c);
|
||||||
@@ -584,6 +608,7 @@ impl CliProcessManager {
|
|||||||
let app_clone = app.clone();
|
let app_clone = app.clone();
|
||||||
let ready_clone = ready.clone();
|
let ready_clone = ready.clone();
|
||||||
let token_clone = bootstrap_token.clone();
|
let token_clone = bootstrap_token.clone();
|
||||||
|
let auth_cookie_name_clone = auth_cookie_name.clone();
|
||||||
|
|
||||||
thread::spawn(move || {
|
thread::spawn(move || {
|
||||||
let stdout = child_clone
|
let stdout = child_clone
|
||||||
@@ -598,24 +623,41 @@ impl CliProcessManager {
|
|||||||
.map(BufReader::new);
|
.map(BufReader::new);
|
||||||
|
|
||||||
if let Some(reader) = stdout {
|
if let Some(reader) = stdout {
|
||||||
Self::process_stream(
|
let app = app_clone.clone();
|
||||||
reader,
|
let status = status_clone.clone();
|
||||||
"stdout",
|
let ready = ready_clone.clone();
|
||||||
&app_clone,
|
let token = token_clone.clone();
|
||||||
&status_clone,
|
let auth_cookie_name = auth_cookie_name_clone.clone();
|
||||||
&ready_clone,
|
thread::spawn(move || {
|
||||||
&token_clone,
|
Self::process_stream(
|
||||||
);
|
reader,
|
||||||
|
"stdout",
|
||||||
|
&app,
|
||||||
|
&status,
|
||||||
|
&ready,
|
||||||
|
&token,
|
||||||
|
auth_cookie_name.as_str(),
|
||||||
|
);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if let Some(reader) = stderr {
|
if let Some(reader) = stderr {
|
||||||
Self::process_stream(
|
let app = app_clone.clone();
|
||||||
reader,
|
let status = status_clone.clone();
|
||||||
"stderr",
|
let ready = ready_clone.clone();
|
||||||
&app_clone,
|
let token = token_clone.clone();
|
||||||
&status_clone,
|
let auth_cookie_name = auth_cookie_name_clone.clone();
|
||||||
&ready_clone,
|
thread::spawn(move || {
|
||||||
&token_clone,
|
Self::process_stream(
|
||||||
);
|
reader,
|
||||||
|
"stderr",
|
||||||
|
&app,
|
||||||
|
&status,
|
||||||
|
&ready,
|
||||||
|
&token,
|
||||||
|
auth_cookie_name.as_str(),
|
||||||
|
);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -731,10 +773,10 @@ impl CliProcessManager {
|
|||||||
status: &Arc<Mutex<CliStatus>>,
|
status: &Arc<Mutex<CliStatus>>,
|
||||||
ready: &Arc<AtomicBool>,
|
ready: &Arc<AtomicBool>,
|
||||||
bootstrap_token: &Arc<Mutex<Option<String>>>,
|
bootstrap_token: &Arc<Mutex<Option<String>>>,
|
||||||
|
auth_cookie_name: &str,
|
||||||
) {
|
) {
|
||||||
let mut buffer = String::new();
|
let mut buffer = String::new();
|
||||||
let local_url_regex = Regex::new(r"^Local\s+Connection\s+URL\s*:\s*(https?://\S+)").ok();
|
let local_url_regex = Regex::new(r"^Local\s+Connection\s+URL\s*:\s*(https?://\S+)\s*$").ok();
|
||||||
let http_regex = Regex::new(r":(\d{2,5})(?!.*:\d)").ok();
|
|
||||||
let token_prefix = "CODENOMAD_BOOTSTRAP_TOKEN:";
|
let token_prefix = "CODENOMAD_BOOTSTRAP_TOKEN:";
|
||||||
|
|
||||||
loop {
|
loop {
|
||||||
@@ -766,39 +808,17 @@ impl CliProcessManager {
|
|||||||
.and_then(|re| re.captures(line).and_then(|c| c.get(1)))
|
.and_then(|re| re.captures(line).and_then(|c| c.get(1)))
|
||||||
.map(|m| m.as_str().to_string())
|
.map(|m| m.as_str().to_string())
|
||||||
{
|
{
|
||||||
Self::mark_ready(app, status, ready, bootstrap_token, url);
|
Self::mark_ready(
|
||||||
|
app,
|
||||||
|
status,
|
||||||
|
ready,
|
||||||
|
bootstrap_token,
|
||||||
|
auth_cookie_name,
|
||||||
|
url,
|
||||||
|
);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
if line.to_lowercase().contains("http server listening") {
|
|
||||||
if let Some(port) = http_regex
|
|
||||||
.as_ref()
|
|
||||||
.and_then(|re| re.captures(line).and_then(|c| c.get(1)))
|
|
||||||
.and_then(|m| m.as_str().parse::<u16>().ok())
|
|
||||||
{
|
|
||||||
Self::mark_ready(
|
|
||||||
app,
|
|
||||||
status,
|
|
||||||
ready,
|
|
||||||
bootstrap_token,
|
|
||||||
format!("http://localhost:{port}"),
|
|
||||||
);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if let Ok(value) = serde_json::from_str::<serde_json::Value>(line) {
|
|
||||||
if let Some(port) = value.get("port").and_then(|p| p.as_u64()) {
|
|
||||||
Self::mark_ready(
|
|
||||||
app,
|
|
||||||
status,
|
|
||||||
ready,
|
|
||||||
bootstrap_token,
|
|
||||||
format!("http://localhost:{}", port),
|
|
||||||
);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Err(_) => break,
|
Err(_) => break,
|
||||||
@@ -811,6 +831,7 @@ impl CliProcessManager {
|
|||||||
status: &Arc<Mutex<CliStatus>>,
|
status: &Arc<Mutex<CliStatus>>,
|
||||||
ready: &Arc<AtomicBool>,
|
ready: &Arc<AtomicBool>,
|
||||||
bootstrap_token: &Arc<Mutex<Option<String>>>,
|
bootstrap_token: &Arc<Mutex<Option<String>>>,
|
||||||
|
auth_cookie_name: &str,
|
||||||
base_url: String,
|
base_url: String,
|
||||||
) {
|
) {
|
||||||
ready.store(true, Ordering::SeqCst);
|
ready.store(true, Ordering::SeqCst);
|
||||||
@@ -834,9 +855,11 @@ impl CliProcessManager {
|
|||||||
if scheme.as_deref() != Some("http") {
|
if scheme.as_deref() != Some("http") {
|
||||||
navigate_main(app, &base_url);
|
navigate_main(app, &base_url);
|
||||||
} else {
|
} else {
|
||||||
match exchange_bootstrap_token(&base_url, &token) {
|
match exchange_bootstrap_token(&base_url, &token, &auth_cookie_name) {
|
||||||
Ok(Some(session_id)) => {
|
Ok(Some(session_id)) => {
|
||||||
if let Err(err) = set_session_cookie(app, &base_url, &session_id) {
|
if let Err(err) =
|
||||||
|
set_session_cookie(app, &base_url, &auth_cookie_name, &session_id)
|
||||||
|
{
|
||||||
log_line(&format!("failed to set session cookie: {err}"));
|
log_line(&format!("failed to set session cookie: {err}"));
|
||||||
navigate_main(app, &format!("{base_url}/login"));
|
navigate_main(app, &format!("{base_url}/login"));
|
||||||
} else {
|
} else {
|
||||||
@@ -932,11 +955,13 @@ impl CliEntry {
|
|||||||
))
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
fn build_args(&self, dev: bool, host: &str) -> Vec<String> {
|
fn build_args(&self, dev: bool, host: &str, auth_cookie_name: &str) -> Vec<String> {
|
||||||
let mut args = vec![
|
let mut args = vec![
|
||||||
"serve".to_string(),
|
"serve".to_string(),
|
||||||
"--host".to_string(),
|
"--host".to_string(),
|
||||||
host.to_string(),
|
host.to_string(),
|
||||||
|
"--auth-cookie-name".to_string(),
|
||||||
|
auth_cookie_name.to_string(),
|
||||||
"--generate-token".to_string(),
|
"--generate-token".to_string(),
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -993,27 +1018,50 @@ impl CliEntry {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn resolve_tsx(_app: &AppHandle) -> Option<String> {
|
fn resolve_tsx(_app: &AppHandle) -> Option<String> {
|
||||||
let candidates = vec![
|
let cwd = std::env::current_dir().ok();
|
||||||
std::env::current_dir()
|
let workspace = workspace_root();
|
||||||
.ok()
|
let mut candidates = vec![
|
||||||
|
cwd.as_ref().map(|p| p.join("node_modules/tsx/dist/cli.mjs")),
|
||||||
|
cwd.as_ref().map(|p| p.join("node_modules/tsx/dist/cli.cjs")),
|
||||||
|
cwd.as_ref().map(|p| p.join("node_modules/tsx/dist/cli.js")),
|
||||||
|
cwd.as_ref().map(|p| p.join("../node_modules/tsx/dist/cli.mjs")),
|
||||||
|
cwd.as_ref().map(|p| p.join("../node_modules/tsx/dist/cli.cjs")),
|
||||||
|
cwd.as_ref().map(|p| p.join("../node_modules/tsx/dist/cli.js")),
|
||||||
|
cwd.as_ref().map(|p| p.join("../../node_modules/tsx/dist/cli.mjs")),
|
||||||
|
cwd.as_ref().map(|p| p.join("../../node_modules/tsx/dist/cli.cjs")),
|
||||||
|
cwd.as_ref().map(|p| p.join("../../node_modules/tsx/dist/cli.js")),
|
||||||
|
workspace
|
||||||
|
.as_ref()
|
||||||
|
.map(|p| p.join("node_modules/tsx/dist/cli.mjs")),
|
||||||
|
workspace
|
||||||
|
.as_ref()
|
||||||
|
.map(|p| p.join("node_modules/tsx/dist/cli.cjs")),
|
||||||
|
workspace
|
||||||
|
.as_ref()
|
||||||
.map(|p| p.join("node_modules/tsx/dist/cli.js")),
|
.map(|p| p.join("node_modules/tsx/dist/cli.js")),
|
||||||
std::env::current_exe().ok().and_then(|ex| {
|
|
||||||
ex.parent()
|
|
||||||
.map(|p| p.join("../node_modules/tsx/dist/cli.js"))
|
|
||||||
}),
|
|
||||||
];
|
];
|
||||||
|
|
||||||
|
if let Ok(exe) = std::env::current_exe() {
|
||||||
|
if let Some(dir) = exe.parent() {
|
||||||
|
candidates.push(Some(dir.join("../node_modules/tsx/dist/cli.mjs")));
|
||||||
|
candidates.push(Some(dir.join("../node_modules/tsx/dist/cli.cjs")));
|
||||||
|
candidates.push(Some(dir.join("../node_modules/tsx/dist/cli.js")));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
first_existing(candidates)
|
first_existing(candidates)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn resolve_dev_entry(_app: &AppHandle) -> Option<String> {
|
fn resolve_dev_entry(_app: &AppHandle) -> Option<String> {
|
||||||
|
let cwd = std::env::current_dir().ok();
|
||||||
|
let workspace = workspace_root();
|
||||||
let candidates = vec![
|
let candidates = vec![
|
||||||
std::env::current_dir()
|
workspace
|
||||||
.ok()
|
.as_ref()
|
||||||
.map(|p| p.join("packages/server/src/index.ts")),
|
.map(|p| p.join("packages/server/src/index.ts")),
|
||||||
std::env::current_dir()
|
cwd.as_ref().map(|p| p.join("packages/server/src/index.ts")),
|
||||||
.ok()
|
cwd.as_ref().map(|p| p.join("../server/src/index.ts")),
|
||||||
.map(|p| p.join("../server/src/index.ts")),
|
cwd.as_ref().map(|p| p.join("../../server/src/index.ts")),
|
||||||
];
|
];
|
||||||
|
|
||||||
first_existing(candidates)
|
first_existing(candidates)
|
||||||
@@ -1115,11 +1163,8 @@ fn build_shell_args(shell: &str, command: &str) -> Vec<String> {
|
|||||||
.unwrap_or("")
|
.unwrap_or("")
|
||||||
.to_lowercase();
|
.to_lowercase();
|
||||||
|
|
||||||
if shell_name.contains("zsh") {
|
let _ = shell_name;
|
||||||
vec!["-l".into(), "-i".into(), "-c".into(), command.into()]
|
vec!["-l".into(), "-c".into(), command.into()]
|
||||||
} else {
|
|
||||||
vec!["-l".into(), "-c".into(), command.into()]
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn first_existing(paths: Vec<Option<PathBuf>>) -> Option<String> {
|
fn first_existing(paths: Vec<Option<PathBuf>>) -> Option<String> {
|
||||||
|
|||||||
@@ -6,13 +6,14 @@ use cli_manager::{CliProcessManager, CliStatus};
|
|||||||
use keepawake::KeepAwake;
|
use keepawake::KeepAwake;
|
||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
use serde_json::json;
|
use serde_json::json;
|
||||||
|
use std::collections::HashMap;
|
||||||
use std::sync::atomic::{AtomicBool, Ordering};
|
use std::sync::atomic::{AtomicBool, Ordering};
|
||||||
use std::sync::Mutex;
|
use std::sync::Mutex;
|
||||||
use std::time::{SystemTime, UNIX_EPOCH};
|
use std::time::{SystemTime, UNIX_EPOCH};
|
||||||
use tauri::menu::{MenuBuilder, MenuItem, SubmenuBuilder};
|
use tauri::menu::{MenuBuilder, MenuItem, SubmenuBuilder};
|
||||||
use tauri::plugin::{Builder as PluginBuilder, TauriPlugin};
|
use tauri::plugin::{Builder as PluginBuilder, TauriPlugin};
|
||||||
use tauri::webview::Webview;
|
use tauri::webview::Webview;
|
||||||
use tauri::{AppHandle, Emitter, Manager, Runtime, WindowEvent, Wry};
|
use tauri::{AppHandle, Emitter, Manager, Runtime, WebviewUrl, WebviewWindowBuilder, WindowEvent, Wry};
|
||||||
use tauri_plugin_global_shortcut::{
|
use tauri_plugin_global_shortcut::{
|
||||||
Code as ShortcutCode, GlobalShortcutExt, Shortcut, ShortcutState,
|
Code as ShortcutCode, GlobalShortcutExt, Shortcut, ShortcutState,
|
||||||
};
|
};
|
||||||
@@ -41,6 +42,16 @@ pub struct AppState {
|
|||||||
pub manager: CliProcessManager,
|
pub manager: CliProcessManager,
|
||||||
pub wake_lock: Mutex<Option<KeepAwake>>,
|
pub wake_lock: Mutex<Option<KeepAwake>>,
|
||||||
pub zoom_level: Mutex<f64>,
|
pub zoom_level: Mutex<f64>,
|
||||||
|
pub remote_origins: Mutex<HashMap<String, String>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
struct RemoteWindowPayload {
|
||||||
|
id: String,
|
||||||
|
name: String,
|
||||||
|
base_url: String,
|
||||||
|
skip_tls_verify: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Default, Deserialize)]
|
#[derive(Debug, Default, Deserialize)]
|
||||||
@@ -118,11 +129,28 @@ fn should_allow_internal(url: &Url) -> bool {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn intercept_navigation<R: Runtime>(webview: &Webview<R>, url: &Url) -> bool {
|
fn should_allow_window_origin<R: Runtime>(app_handle: &AppHandle<R>, window_label: &str, url: &Url) -> bool {
|
||||||
if should_allow_internal(url) {
|
if should_allow_internal(url) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let state = app_handle.state::<AppState>();
|
||||||
|
let Ok(allowed) = state.remote_origins.lock() else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
if let Some(origin) = allowed.get(window_label) {
|
||||||
|
return origin == &url.origin().ascii_serialization();
|
||||||
|
}
|
||||||
|
|
||||||
|
false
|
||||||
|
}
|
||||||
|
|
||||||
|
fn intercept_navigation<R: Runtime>(webview: &Webview<R>, url: &Url) -> bool {
|
||||||
|
let window_label = webview.label().to_string();
|
||||||
|
if should_allow_window_origin(&webview.app_handle(), &window_label, url) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
if let Err(err) = webview
|
if let Err(err) = webview
|
||||||
.app_handle()
|
.app_handle()
|
||||||
.opener()
|
.opener()
|
||||||
@@ -133,6 +161,53 @@ fn intercept_navigation<R: Runtime>(webview: &Webview<R>, url: &Url) -> bool {
|
|||||||
false
|
false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
fn open_remote_window(app: AppHandle, payload: RemoteWindowPayload) -> Result<(), String> {
|
||||||
|
if payload.skip_tls_verify && payload.base_url.starts_with("https://") {
|
||||||
|
return Err(
|
||||||
|
"Tauri cannot bypass self-signed HTTPS certificates automatically yet. Trust the certificate in your OS first, then reconnect, or use the CodeNomad Electron app."
|
||||||
|
.to_string(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
let parsed = Url::parse(&payload.base_url).map_err(|err| err.to_string())?;
|
||||||
|
let label = format!("remote-{}", payload.id);
|
||||||
|
let title = format!("{} - {}", payload.name, parsed.host_str().unwrap_or(payload.base_url.as_str()));
|
||||||
|
|
||||||
|
if let Some(existing) = app.get_webview_window(&label) {
|
||||||
|
let _ = existing.navigate(parsed.clone());
|
||||||
|
let _ = existing.set_title(&title);
|
||||||
|
let _ = existing.show();
|
||||||
|
let _ = existing.unminimize();
|
||||||
|
let _ = existing.set_focus();
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
app.state::<AppState>()
|
||||||
|
.remote_origins
|
||||||
|
.lock()
|
||||||
|
.map_err(|err| err.to_string())?
|
||||||
|
.insert(label.clone(), parsed.origin().ascii_serialization());
|
||||||
|
|
||||||
|
let window = WebviewWindowBuilder::new(&app, label.clone(), WebviewUrl::External(parsed.clone()))
|
||||||
|
.title(title)
|
||||||
|
.inner_size(1400.0, 900.0)
|
||||||
|
.min_inner_size(800.0, 600.0)
|
||||||
|
.build()
|
||||||
|
.map_err(|err| err.to_string())?;
|
||||||
|
|
||||||
|
let app_handle = app.clone();
|
||||||
|
window.on_window_event(move |event| {
|
||||||
|
if let WindowEvent::Destroyed = event {
|
||||||
|
if let Ok(mut origins) = app_handle.state::<AppState>().remote_origins.lock() {
|
||||||
|
origins.remove(&label);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
fn collect_directory_paths(paths: &[std::path::PathBuf]) -> Vec<String> {
|
fn collect_directory_paths(paths: &[std::path::PathBuf]) -> Vec<String> {
|
||||||
paths
|
paths
|
||||||
.iter()
|
.iter()
|
||||||
@@ -286,6 +361,7 @@ fn main() {
|
|||||||
manager: CliProcessManager::new(),
|
manager: CliProcessManager::new(),
|
||||||
wake_lock: Mutex::new(None),
|
wake_lock: Mutex::new(None),
|
||||||
zoom_level: Mutex::new(DEFAULT_ZOOM_LEVEL),
|
zoom_level: Mutex::new(DEFAULT_ZOOM_LEVEL),
|
||||||
|
remote_origins: Mutex::new(HashMap::new()),
|
||||||
})
|
})
|
||||||
.setup(|app| {
|
.setup(|app| {
|
||||||
set_windows_app_user_model_id();
|
set_windows_app_user_model_id();
|
||||||
@@ -323,7 +399,8 @@ fn main() {
|
|||||||
cli_get_status,
|
cli_get_status,
|
||||||
cli_restart,
|
cli_restart,
|
||||||
wake_lock_start,
|
wake_lock_start,
|
||||||
wake_lock_stop
|
wake_lock_stop,
|
||||||
|
open_remote_window
|
||||||
])
|
])
|
||||||
.on_menu_event(|app_handle, event| {
|
.on_menu_event(|app_handle, event| {
|
||||||
match event.id().0.as_str() {
|
match event.id().0.as_str() {
|
||||||
@@ -455,11 +532,24 @@ fn main() {
|
|||||||
event: tauri::WindowEvent::CloseRequested { api, .. },
|
event: tauri::WindowEvent::CloseRequested { api, .. },
|
||||||
..
|
..
|
||||||
} => {
|
} => {
|
||||||
// Ensure we have time to stop the CLI process before the app exits.
|
// Let windows close normally. App shutdown is handled only after the
|
||||||
|
// last window is actually gone so remote windows can outlive `main`.
|
||||||
|
let _ = api;
|
||||||
|
}
|
||||||
|
tauri::RunEvent::WindowEvent {
|
||||||
|
event: tauri::WindowEvent::Destroyed,
|
||||||
|
..
|
||||||
|
} => {
|
||||||
|
if !app_handle.webview_windows().is_empty() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stop the CLI only when the final window is gone and the app is
|
||||||
|
// truly exiting.
|
||||||
if QUIT_REQUESTED.swap(true, Ordering::SeqCst) {
|
if QUIT_REQUESTED.swap(true, Ordering::SeqCst) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
api.prevent_close();
|
|
||||||
let app = app_handle.clone();
|
let app = app_handle.clone();
|
||||||
std::thread::spawn(move || {
|
std::thread::spawn(move || {
|
||||||
if let Some(state) = app.try_state::<AppState>() {
|
if let Some(state) = app.try_state::<AppState>() {
|
||||||
|
|||||||
@@ -1,16 +1,13 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://schema.tauri.app/config/2",
|
"$schema": "https://schema.tauri.app/config/2",
|
||||||
"productName": "CodeNomad",
|
"productName": "CodeNomad",
|
||||||
"version": "0.12.3",
|
"version": "0.13.3",
|
||||||
"identifier": "ai.neuralnomads.codenomad.client",
|
"identifier": "ai.neuralnomads.codenomad.client",
|
||||||
"build": {
|
"build": {
|
||||||
"beforeDevCommand": "npm run dev:bootstrap",
|
"beforeDevCommand": "npm run dev:bootstrap",
|
||||||
"beforeBuildCommand": "npm run bundle:server",
|
"beforeBuildCommand": "npm run bundle:server",
|
||||||
"frontendDist": "resources/ui-loading"
|
"frontendDist": "resources/ui-loading"
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
"app": {
|
"app": {
|
||||||
"withGlobalTauri": true,
|
"withGlobalTauri": true,
|
||||||
"windows": [
|
"windows": [
|
||||||
@@ -33,9 +30,13 @@
|
|||||||
],
|
],
|
||||||
"security": {
|
"security": {
|
||||||
"assetProtocol": {
|
"assetProtocol": {
|
||||||
"scope": ["**"]
|
"scope": [
|
||||||
|
"**"
|
||||||
|
]
|
||||||
},
|
},
|
||||||
"capabilities": ["main-window-native-dialogs"]
|
"capabilities": [
|
||||||
|
"main-window-native-dialogs"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"bundle": {
|
"bundle": {
|
||||||
@@ -44,7 +45,17 @@
|
|||||||
"resources/server",
|
"resources/server",
|
||||||
"resources/ui-loading"
|
"resources/ui-loading"
|
||||||
],
|
],
|
||||||
"icon": ["icon.icns", "icon.ico", "icon.png"],
|
"icon": [
|
||||||
"targets": ["app", "appimage", "deb", "rpm", "nsis"]
|
"icon.icns",
|
||||||
|
"icon.ico",
|
||||||
|
"icon.png"
|
||||||
|
],
|
||||||
|
"targets": [
|
||||||
|
"app",
|
||||||
|
"appimage",
|
||||||
|
"deb",
|
||||||
|
"rpm",
|
||||||
|
"nsis"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@codenomad/ui",
|
"name": "@codenomad/ui",
|
||||||
"version": "0.13.1",
|
"version": "0.13.3",
|
||||||
"private": true,
|
"private": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
|
|||||||
@@ -10,7 +10,10 @@ import InstanceTabs from "./components/instance-tabs"
|
|||||||
import InstanceDisconnectedModal from "./components/instance-disconnected-modal"
|
import InstanceDisconnectedModal from "./components/instance-disconnected-modal"
|
||||||
import InstanceShell from "./components/instance/instance-shell2"
|
import InstanceShell from "./components/instance/instance-shell2"
|
||||||
import { SettingsScreen } from "./components/settings-screen"
|
import { SettingsScreen } from "./components/settings-screen"
|
||||||
|
import { SideCarPickerDialog } from "./components/sidecar-picker-dialog"
|
||||||
|
import { SideCarView } from "./components/sidecar-view"
|
||||||
import { InstanceMetadataProvider } from "./lib/contexts/instance-metadata-context"
|
import { InstanceMetadataProvider } from "./lib/contexts/instance-metadata-context"
|
||||||
|
import { showAlertDialog } from "./stores/alerts"
|
||||||
import { initGithubStars } from "./stores/github-stars"
|
import { initGithubStars } from "./stores/github-stars"
|
||||||
|
|
||||||
import { useCommands } from "./lib/hooks/use-commands"
|
import { useCommands } from "./lib/hooks/use-commands"
|
||||||
@@ -23,7 +26,6 @@ import { runtimeEnv } from "./lib/runtime-env"
|
|||||||
import { useI18n } from "./lib/i18n"
|
import { useI18n } from "./lib/i18n"
|
||||||
import { setWakeLockDesired } from "./lib/native/wake-lock"
|
import { setWakeLockDesired } from "./lib/native/wake-lock"
|
||||||
import {
|
import {
|
||||||
hasInstances,
|
|
||||||
isSelectingFolder,
|
isSelectingFolder,
|
||||||
setIsSelectingFolder,
|
setIsSelectingFolder,
|
||||||
showFolderSelection,
|
showFolderSelection,
|
||||||
@@ -33,10 +35,7 @@ import { useConfig } from "./stores/preferences"
|
|||||||
import {
|
import {
|
||||||
createInstance,
|
createInstance,
|
||||||
instances,
|
instances,
|
||||||
activeInstanceId,
|
|
||||||
setActiveInstanceId,
|
|
||||||
stopInstance,
|
stopInstance,
|
||||||
getActiveInstance,
|
|
||||||
disconnectedInstance,
|
disconnectedInstance,
|
||||||
acknowledgeDisconnectedInstance,
|
acknowledgeDisconnectedInstance,
|
||||||
} from "./stores/instances"
|
} from "./stores/instances"
|
||||||
@@ -53,6 +52,22 @@ import {
|
|||||||
|
|
||||||
import { getInstanceSessionIndicatorStatus } from "./stores/session-status"
|
import { getInstanceSessionIndicatorStatus } from "./stores/session-status"
|
||||||
import { openSettings } from "./stores/settings-screen"
|
import { openSettings } from "./stores/settings-screen"
|
||||||
|
import {
|
||||||
|
closeSidecarTab,
|
||||||
|
ensureSidecarsLoaded,
|
||||||
|
openSidecarTab,
|
||||||
|
} from "./stores/sidecars"
|
||||||
|
import {
|
||||||
|
activeAppTab,
|
||||||
|
activeAppTabId,
|
||||||
|
appTabs,
|
||||||
|
ensureActiveAppTab,
|
||||||
|
getAdjacentAppTabId,
|
||||||
|
getAppTabById,
|
||||||
|
selectAppTab,
|
||||||
|
selectInstanceTab,
|
||||||
|
selectSidecarTab,
|
||||||
|
} from "./stores/app-tabs"
|
||||||
|
|
||||||
const log = getLogger("actions")
|
const log = getLogger("actions")
|
||||||
|
|
||||||
@@ -77,6 +92,7 @@ const App: Component = () => {
|
|||||||
} = useConfig()
|
} = useConfig()
|
||||||
const [escapeInDebounce, setEscapeInDebounce] = createSignal(false)
|
const [escapeInDebounce, setEscapeInDebounce] = createSignal(false)
|
||||||
const [instanceTabBarHeight, setInstanceTabBarHeight] = createSignal(0)
|
const [instanceTabBarHeight, setInstanceTabBarHeight] = createSignal(0)
|
||||||
|
const [sidecarPickerOpen, setSidecarPickerOpen] = createSignal(false)
|
||||||
|
|
||||||
const phoneQuery = useMediaQuery("(max-width: 767px)")
|
const phoneQuery = useMediaQuery("(max-width: 767px)")
|
||||||
const isPhoneLayout = createMemo(() => phoneQuery())
|
const isPhoneLayout = createMemo(() => phoneQuery())
|
||||||
@@ -206,8 +222,7 @@ const App: Component = () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
instances()
|
appTabs()
|
||||||
hasInstances()
|
|
||||||
requestAnimationFrame(() => updateInstanceTabBarHeight())
|
requestAnimationFrame(() => updateInstanceTabBarHeight())
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -219,7 +234,15 @@ const App: Component = () => {
|
|||||||
onCleanup(() => window.removeEventListener("resize", handleResize))
|
onCleanup(() => window.removeEventListener("resize", handleResize))
|
||||||
})
|
})
|
||||||
|
|
||||||
const activeInstance = createMemo(() => getActiveInstance())
|
createEffect(() => {
|
||||||
|
appTabs()
|
||||||
|
ensureActiveAppTab()
|
||||||
|
})
|
||||||
|
|
||||||
|
const activeInstance = createMemo(() => {
|
||||||
|
const tab = activeAppTab()
|
||||||
|
return tab?.kind === "instance" ? tab.instance : null
|
||||||
|
})
|
||||||
const activeSessionIdForInstance = createMemo(() => {
|
const activeSessionIdForInstance = createMemo(() => {
|
||||||
const instance = activeInstance()
|
const instance = activeInstance()
|
||||||
if (!instance) return null
|
if (!instance) return null
|
||||||
@@ -244,6 +267,7 @@ const App: Component = () => {
|
|||||||
recordWorkspaceLaunch(folderPath, selectedBinary)
|
recordWorkspaceLaunch(folderPath, selectedBinary)
|
||||||
clearLaunchError()
|
clearLaunchError()
|
||||||
const instanceId = await createInstance(folderPath, selectedBinary)
|
const instanceId = await createInstance(folderPath, selectedBinary)
|
||||||
|
selectInstanceTab(instanceId)
|
||||||
setShowFolderSelection(false)
|
setShowFolderSelection(false)
|
||||||
|
|
||||||
log.info("Created instance", {
|
log.info("Created instance", {
|
||||||
@@ -270,8 +294,27 @@ const App: Component = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function handleNewInstanceRequest() {
|
function handleNewInstanceRequest() {
|
||||||
if (hasInstances()) {
|
setShowFolderSelection(true)
|
||||||
setShowFolderSelection(true)
|
}
|
||||||
|
|
||||||
|
function handleOpenSidecarPicker() {
|
||||||
|
setSidecarPickerOpen(true)
|
||||||
|
void ensureSidecarsLoaded()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleOpenSidecar(sidecarId: string) {
|
||||||
|
try {
|
||||||
|
const tab = await openSidecarTab(sidecarId)
|
||||||
|
selectSidecarTab(tab.token)
|
||||||
|
setShowFolderSelection(false)
|
||||||
|
setSidecarPickerOpen(false)
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : String(error)
|
||||||
|
showAlertDialog(message, {
|
||||||
|
variant: "error",
|
||||||
|
title: t("sidecars.open.errorTitle"),
|
||||||
|
})
|
||||||
|
log.error("Failed to open SideCar", error)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -332,6 +375,23 @@ const App: Component = () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function handleCloseAppTab(tabId: string) {
|
||||||
|
const tab = getAppTabById(tabId)
|
||||||
|
if (!tab) return
|
||||||
|
|
||||||
|
const fallbackTabId = activeAppTabId() === tabId ? getAdjacentAppTabId(tabId) : activeAppTabId()
|
||||||
|
|
||||||
|
if (tab.kind === "instance") {
|
||||||
|
await handleCloseInstance(tab.instance.id)
|
||||||
|
} else {
|
||||||
|
closeSidecarTab(tab.sidecarTab.token)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!getAppTabById(tabId)) {
|
||||||
|
ensureActiveAppTab(fallbackTabId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const handleSidebarAgentChange = async (instanceId: string, sessionId: string, agent: string) => {
|
const handleSidebarAgentChange = async (instanceId: string, sessionId: string, agent: string) => {
|
||||||
if (!instanceId || !sessionId || sessionId === "info") return
|
if (!instanceId || !sessionId || sessionId === "info") return
|
||||||
await updateSessionAgent(instanceId, sessionId, agent)
|
await updateSessionAgent(instanceId, sessionId, agent)
|
||||||
@@ -361,6 +421,7 @@ const App: Component = () => {
|
|||||||
setThinkingBlocksExpansion,
|
setThinkingBlocksExpansion,
|
||||||
setToolInputsVisibility,
|
setToolInputsVisibility,
|
||||||
handleNewInstanceRequest,
|
handleNewInstanceRequest,
|
||||||
|
handleCloseActiveTab: () => handleCloseAppTab(activeAppTabId() ?? ""),
|
||||||
handleCloseInstance,
|
handleCloseInstance,
|
||||||
handleNewSession,
|
handleNewSession,
|
||||||
handleCloseSession,
|
handleCloseSession,
|
||||||
@@ -371,6 +432,7 @@ const App: Component = () => {
|
|||||||
useAppLifecycle({
|
useAppLifecycle({
|
||||||
setEscapeInDebounce,
|
setEscapeInDebounce,
|
||||||
handleNewInstanceRequest,
|
handleNewInstanceRequest,
|
||||||
|
handleCloseActiveTab: () => handleCloseAppTab(activeAppTabId() ?? ""),
|
||||||
handleCloseInstance,
|
handleCloseInstance,
|
||||||
handleNewSession,
|
handleNewSession,
|
||||||
handleCloseSession,
|
handleCloseSession,
|
||||||
@@ -470,52 +532,60 @@ const App: Component = () => {
|
|||||||
</div>
|
</div>
|
||||||
</Show>
|
</Show>
|
||||||
<Show
|
<Show
|
||||||
when={!hasInstances()}
|
when={appTabs().length === 0}
|
||||||
fallback={
|
fallback={
|
||||||
<>
|
<>
|
||||||
<Show when={!isPhoneLayout() || !mobileFullscreenMode()}>
|
<Show when={!isPhoneLayout() || !mobileFullscreenMode()}>
|
||||||
<InstanceTabs
|
<InstanceTabs
|
||||||
instances={instances()}
|
tabs={appTabs()}
|
||||||
activeInstanceId={activeInstanceId()}
|
activeTabId={activeAppTabId()}
|
||||||
onSelect={setActiveInstanceId}
|
onSelect={selectAppTab}
|
||||||
onClose={handleCloseInstance}
|
onClose={(tabId) => void handleCloseAppTab(tabId)}
|
||||||
onNew={handleNewInstanceRequest}
|
onNew={handleNewInstanceRequest}
|
||||||
/>
|
/>
|
||||||
</Show>
|
</Show>
|
||||||
|
|
||||||
<For each={Array.from(instances().values())}>
|
|
||||||
{(instance) => {
|
|
||||||
const isActiveInstance = () => activeInstanceId() === instance.id
|
|
||||||
const isVisible = () => isActiveInstance() && !showFolderSelection()
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
class="flex-1 min-h-0 overflow-hidden"
|
|
||||||
style={{ display: isVisible() ? "flex" : "none" }}
|
|
||||||
data-instance-id={instance.id}
|
|
||||||
data-instance-active={isActiveInstance() ? "true" : "false"}
|
|
||||||
data-instance-visible={isVisible() ? "true" : "false"}
|
|
||||||
>
|
|
||||||
<InstanceMetadataProvider instance={instance}>
|
|
||||||
<InstanceShell
|
|
||||||
instance={instance}
|
|
||||||
isActiveInstance={isActiveInstance()}
|
|
||||||
escapeInDebounce={escapeInDebounce()}
|
|
||||||
paletteCommands={paletteCommands}
|
|
||||||
onCloseSession={(sessionId) => handleCloseSession(instance.id, sessionId)}
|
|
||||||
onNewSession={() => handleNewSession(instance.id)}
|
|
||||||
handleSidebarAgentChange={(sessionId, agent) => handleSidebarAgentChange(instance.id, sessionId, agent)}
|
|
||||||
handleSidebarModelChange={(sessionId, model) => handleSidebarModelChange(instance.id, sessionId, model)}
|
|
||||||
onExecuteCommand={executeCommand}
|
|
||||||
tabBarOffset={isPhoneLayout() && mobileFullscreenMode() ? 0 : instanceTabBarHeight()}
|
|
||||||
mobileFullscreenMode={isPhoneLayout() && mobileFullscreenMode()}
|
|
||||||
onEnterMobileFullscreen={() => void enterMobileFullscreen()}
|
|
||||||
onExitMobileFullscreen={() => void exitMobileFullscreen()}
|
|
||||||
/>
|
|
||||||
</InstanceMetadataProvider>
|
|
||||||
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
|
|
||||||
|
<For each={appTabs()}>
|
||||||
|
{(tab) => {
|
||||||
|
const isVisible = () => activeAppTabId() === tab.id && !showFolderSelection()
|
||||||
|
return tab.kind === "instance" ? (
|
||||||
|
<div
|
||||||
|
class="flex-1 min-h-0 overflow-hidden"
|
||||||
|
style={{ display: isVisible() ? "flex" : "none" }}
|
||||||
|
data-instance-id={tab.instance.id}
|
||||||
|
data-tab-id={tab.id}
|
||||||
|
data-tab-kind={tab.kind}
|
||||||
|
data-tab-visible={isVisible() ? "true" : "false"}
|
||||||
|
>
|
||||||
|
<InstanceMetadataProvider instance={tab.instance}>
|
||||||
|
<InstanceShell
|
||||||
|
instance={tab.instance}
|
||||||
|
isActiveInstance={isVisible()}
|
||||||
|
escapeInDebounce={escapeInDebounce()}
|
||||||
|
paletteCommands={paletteCommands}
|
||||||
|
onCloseSession={(sessionId) => handleCloseSession(tab.instance.id, sessionId)}
|
||||||
|
onNewSession={() => handleNewSession(tab.instance.id)}
|
||||||
|
handleSidebarAgentChange={(sessionId, agent) => handleSidebarAgentChange(tab.instance.id, sessionId, agent)}
|
||||||
|
handleSidebarModelChange={(sessionId, model) => handleSidebarModelChange(tab.instance.id, sessionId, model)}
|
||||||
|
onExecuteCommand={executeCommand}
|
||||||
|
tabBarOffset={isPhoneLayout() && mobileFullscreenMode() ? 0 : instanceTabBarHeight()}
|
||||||
|
mobileFullscreenMode={isPhoneLayout() && mobileFullscreenMode()}
|
||||||
|
onEnterMobileFullscreen={() => void enterMobileFullscreen()}
|
||||||
|
onExitMobileFullscreen={() => void exitMobileFullscreen()}
|
||||||
|
/>
|
||||||
|
</InstanceMetadataProvider>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div
|
||||||
|
class="flex-1 min-h-0 overflow-hidden"
|
||||||
|
style={{ display: isVisible() ? "flex" : "none" }}
|
||||||
|
data-tab-id={tab.id}
|
||||||
|
data-tab-kind={tab.kind}
|
||||||
|
data-tab-visible={isVisible() ? "true" : "false"}
|
||||||
|
>
|
||||||
|
<SideCarView tab={tab.sidecarTab} />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
}}
|
}}
|
||||||
</For>
|
</For>
|
||||||
|
|
||||||
@@ -525,6 +595,7 @@ const App: Component = () => {
|
|||||||
<FolderSelectionView
|
<FolderSelectionView
|
||||||
onSelectFolder={handleSelectFolder}
|
onSelectFolder={handleSelectFolder}
|
||||||
isLoading={isSelectingFolder()}
|
isLoading={isSelectingFolder()}
|
||||||
|
onOpenSidecar={handleOpenSidecarPicker}
|
||||||
/>
|
/>
|
||||||
</Show>
|
</Show>
|
||||||
|
|
||||||
@@ -534,6 +605,7 @@ const App: Component = () => {
|
|||||||
<FolderSelectionView
|
<FolderSelectionView
|
||||||
onSelectFolder={handleSelectFolder}
|
onSelectFolder={handleSelectFolder}
|
||||||
isLoading={isSelectingFolder()}
|
isLoading={isSelectingFolder()}
|
||||||
|
onOpenSidecar={handleOpenSidecarPicker}
|
||||||
onClose={() => {
|
onClose={() => {
|
||||||
setShowFolderSelection(false)
|
setShowFolderSelection(false)
|
||||||
clearLaunchError()
|
clearLaunchError()
|
||||||
@@ -544,6 +616,7 @@ const App: Component = () => {
|
|||||||
</Show>
|
</Show>
|
||||||
|
|
||||||
<SettingsScreen />
|
<SettingsScreen />
|
||||||
|
<SideCarPickerDialog open={sidecarPickerOpen()} onClose={() => setSidecarPickerOpen(false)} onOpenSidecar={handleOpenSidecar} />
|
||||||
|
|
||||||
<AlertDialog />
|
<AlertDialog />
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { createMemo, Show, createEffect, onCleanup } from "solid-js"
|
import { createMemo, Show, createEffect } from "solid-js"
|
||||||
import { DiffView, DiffModeEnum } from "@git-diff-view/solid"
|
import { DiffView, DiffModeEnum } from "@git-diff-view/solid"
|
||||||
import "@git-diff-view/solid/styles/diff-view-pure.css"
|
import "@git-diff-view/solid/styles/diff-view-pure.css"
|
||||||
import { disableCache } from "@git-diff-view/core"
|
import { disableCache } from "@git-diff-view/core"
|
||||||
@@ -20,6 +20,7 @@ interface ToolCallDiffViewerProps {
|
|||||||
filePath?: string
|
filePath?: string
|
||||||
theme: "light" | "dark"
|
theme: "light" | "dark"
|
||||||
mode: DiffViewMode
|
mode: DiffViewMode
|
||||||
|
wrap?: boolean
|
||||||
onRendered?: () => void
|
onRendered?: () => void
|
||||||
cachedHtml?: string
|
cachedHtml?: string
|
||||||
cacheEntryParams?: CacheEntryParams
|
cacheEntryParams?: CacheEntryParams
|
||||||
@@ -31,11 +32,183 @@ type DiffData = {
|
|||||||
hunks: string[]
|
hunks: string[]
|
||||||
}
|
}
|
||||||
|
|
||||||
type CaptureContext = {
|
function measureTextWidth(container: HTMLElement, text: string, source: HTMLElement) {
|
||||||
theme: ToolCallDiffViewerProps["theme"]
|
const computed = window.getComputedStyle(source)
|
||||||
mode: DiffViewMode
|
const probe = document.createElement("span")
|
||||||
diffText: string
|
probe.textContent = text || ""
|
||||||
cacheEntryParams?: CacheEntryParams
|
probe.style.position = "absolute"
|
||||||
|
probe.style.visibility = "hidden"
|
||||||
|
probe.style.pointerEvents = "none"
|
||||||
|
probe.style.display = "inline-block"
|
||||||
|
probe.style.width = "auto"
|
||||||
|
probe.style.maxWidth = "none"
|
||||||
|
probe.style.whiteSpace = "nowrap"
|
||||||
|
probe.style.fontFamily = computed.fontFamily
|
||||||
|
probe.style.fontSize = computed.fontSize
|
||||||
|
probe.style.fontWeight = computed.fontWeight
|
||||||
|
probe.style.fontStyle = computed.fontStyle
|
||||||
|
probe.style.letterSpacing = computed.letterSpacing
|
||||||
|
probe.style.fontVariant = computed.fontVariant
|
||||||
|
probe.style.textTransform = computed.textTransform
|
||||||
|
probe.style.lineHeight = computed.lineHeight
|
||||||
|
container.appendChild(probe)
|
||||||
|
const width = Math.ceil(probe.getBoundingClientRect().width)
|
||||||
|
probe.remove()
|
||||||
|
return width
|
||||||
|
}
|
||||||
|
|
||||||
|
function computeCompactWidth(
|
||||||
|
container: HTMLElement,
|
||||||
|
entries: Array<{ text: string; source: HTMLElement }>,
|
||||||
|
maxWidthPx = 40,
|
||||||
|
) {
|
||||||
|
const measuredLabelWidthPx = entries.reduce((max, entry) => {
|
||||||
|
return Math.max(max, measureTextWidth(container, entry.text, entry.source))
|
||||||
|
}, 0)
|
||||||
|
const fallbackTextLength = entries.reduce((max, entry) => Math.max(max, entry.text.length), 1)
|
||||||
|
const fallbackWidthPx = Math.round(fallbackTextLength * 7 + 4)
|
||||||
|
return Math.max(2, Math.min(maxWidthPx, measuredLabelWidthPx > 0 ? measuredLabelWidthPx + 2 : fallbackWidthPx))
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyCompactUnifiedGutter(container: HTMLElement, wrap: boolean) {
|
||||||
|
const tableWrapper = container.querySelector<HTMLElement>(".unified-diff-table-wrapper")
|
||||||
|
const table = container.querySelector<HTMLTableElement>(".unified-diff-table")
|
||||||
|
const numberCol = container.querySelector<HTMLTableColElement>(".unified-diff-table-num-col")
|
||||||
|
const gutterRows = container.querySelectorAll<HTMLElement>(".diff-line-num")
|
||||||
|
const hunkGutters = container.querySelectorAll<HTMLElement>(".diff-line-hunk-action, .diff-line-widget-wrapper, .diff-line-extend-wrapper")
|
||||||
|
const entries: Array<{ gutter: HTMLElement; label: HTMLElement; text: string }> = []
|
||||||
|
|
||||||
|
if (table) {
|
||||||
|
if (wrap) {
|
||||||
|
table.classList.add("table-fixed")
|
||||||
|
table.style.tableLayout = "fixed"
|
||||||
|
table.style.width = "100%"
|
||||||
|
table.style.minWidth = "100%"
|
||||||
|
} else {
|
||||||
|
table.classList.remove("table-fixed")
|
||||||
|
table.style.tableLayout = "auto"
|
||||||
|
table.style.width = "max-content"
|
||||||
|
table.style.minWidth = "100%"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
gutterRows.forEach((gutter) => {
|
||||||
|
const oldSpan = gutter.querySelector<HTMLElement>("[data-line-old-num]")
|
||||||
|
const newSpan = gutter.querySelector<HTMLElement>("[data-line-new-num]")
|
||||||
|
const spacer = gutter.querySelector<HTMLElement>(".shrink-0")
|
||||||
|
const flexWrapper = gutter.querySelector<HTMLElement>(":scope > .flex")
|
||||||
|
const currentLabel = gutter.querySelector<HTMLElement>(":scope > .tool-call-diff-compact-line-number")
|
||||||
|
|
||||||
|
const oldText = oldSpan?.textContent?.trim() ?? ""
|
||||||
|
const newText = newSpan?.textContent?.trim() ?? ""
|
||||||
|
const hasUsableNew = newText.length > 0 && newText !== "0"
|
||||||
|
const hasUsableOld = oldText.length > 0 && oldText !== "0"
|
||||||
|
const visibleText = hasUsableNew ? newText : hasUsableOld ? oldText : newText || oldText
|
||||||
|
|
||||||
|
if (flexWrapper) flexWrapper.style.display = "none"
|
||||||
|
if (spacer) spacer.style.display = "none"
|
||||||
|
if (oldSpan) { oldSpan.style.display = "none"; oldSpan.style.width = "auto" }
|
||||||
|
if (newSpan) { newSpan.style.display = "none"; newSpan.style.width = "auto" }
|
||||||
|
|
||||||
|
gutter.style.paddingLeft = "1px"
|
||||||
|
gutter.style.paddingRight = "1px"
|
||||||
|
gutter.style.textAlign = "left"
|
||||||
|
|
||||||
|
const label = currentLabel ?? document.createElement("span")
|
||||||
|
label.className = "tool-call-diff-compact-line-number"
|
||||||
|
label.textContent = visibleText
|
||||||
|
label.setAttribute("aria-hidden", visibleText ? "false" : "true")
|
||||||
|
if (!currentLabel) gutter.appendChild(label)
|
||||||
|
|
||||||
|
entries.push({ gutter, label, text: visibleText })
|
||||||
|
})
|
||||||
|
|
||||||
|
const gutterWidthPx = computeCompactWidth(container, entries.map((entry) => ({ text: entry.text, source: entry.label })))
|
||||||
|
const gutterWidth = `${gutterWidthPx}px`
|
||||||
|
const compactAsideWidth = `${Math.max(8, gutterWidthPx - 10)}px`
|
||||||
|
|
||||||
|
if (tableWrapper) {
|
||||||
|
tableWrapper.style.setProperty("--diff-aside-width", compactAsideWidth)
|
||||||
|
tableWrapper.style.setProperty("--diff-aside-width--", compactAsideWidth)
|
||||||
|
}
|
||||||
|
if (numberCol) {
|
||||||
|
numberCol.style.width = gutterWidth
|
||||||
|
}
|
||||||
|
|
||||||
|
entries.forEach(({ gutter, label }) => {
|
||||||
|
gutter.style.width = gutterWidth
|
||||||
|
gutter.style.minWidth = gutterWidth
|
||||||
|
gutter.style.maxWidth = gutterWidth
|
||||||
|
label.style.width = "auto"
|
||||||
|
label.style.maxWidth = "none"
|
||||||
|
})
|
||||||
|
|
||||||
|
hunkGutters.forEach((gutter) => {
|
||||||
|
gutter.style.width = gutterWidth
|
||||||
|
gutter.style.minWidth = gutterWidth
|
||||||
|
gutter.style.maxWidth = gutterWidth
|
||||||
|
gutter.style.paddingLeft = "0"
|
||||||
|
gutter.style.paddingRight = "0"
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyCompactSplitGutter(container: HTMLElement) {
|
||||||
|
const oldWrapper = container.querySelector<HTMLElement>(".old-diff-table-wrapper")
|
||||||
|
const newWrapper = container.querySelector<HTMLElement>(".new-diff-table-wrapper")
|
||||||
|
const numberCells = Array.from(container.querySelectorAll<HTMLElement>(".diff-line-old-num, .diff-line-new-num"))
|
||||||
|
const hunkActions = Array.from(container.querySelectorAll<HTMLElement>(".diff-line-hunk-action, .diff-line-widget-wrapper, .diff-line-extend-wrapper"))
|
||||||
|
const numberSpans = numberCells
|
||||||
|
.map((cell) => ({ cell, span: cell.querySelector<HTMLElement>("[data-line-num]") }))
|
||||||
|
.filter((entry): entry is { cell: HTMLElement; span: HTMLElement } => Boolean(entry.span))
|
||||||
|
|
||||||
|
const gutterWidthPx = computeCompactWidth(
|
||||||
|
container,
|
||||||
|
numberSpans.map(({ span }) => ({ text: span.textContent?.trim() ?? "", source: span })),
|
||||||
|
64,
|
||||||
|
)
|
||||||
|
const gutterWidth = `${gutterWidthPx}px`
|
||||||
|
|
||||||
|
;[oldWrapper, newWrapper].forEach((wrapper) => {
|
||||||
|
if (wrapper) {
|
||||||
|
wrapper.style.setProperty("--diff-aside-width", gutterWidth)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
numberCells.forEach((cell) => {
|
||||||
|
cell.style.width = gutterWidth
|
||||||
|
cell.style.minWidth = gutterWidth
|
||||||
|
cell.style.maxWidth = gutterWidth
|
||||||
|
cell.style.paddingLeft = "2px"
|
||||||
|
cell.style.paddingRight = "2px"
|
||||||
|
cell.style.textAlign = "left"
|
||||||
|
cell.style.whiteSpace = "nowrap"
|
||||||
|
cell.style.overflowWrap = "normal"
|
||||||
|
cell.style.wordBreak = "normal"
|
||||||
|
})
|
||||||
|
|
||||||
|
numberSpans.forEach(({ span }) => {
|
||||||
|
span.style.whiteSpace = "nowrap"
|
||||||
|
span.style.overflowWrap = "normal"
|
||||||
|
span.style.wordBreak = "normal"
|
||||||
|
})
|
||||||
|
|
||||||
|
hunkActions.forEach((cell) => {
|
||||||
|
cell.style.width = gutterWidth
|
||||||
|
cell.style.minWidth = gutterWidth
|
||||||
|
cell.style.maxWidth = gutterWidth
|
||||||
|
cell.style.paddingLeft = "0"
|
||||||
|
cell.style.paddingRight = "0"
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyCompactDiffLayout(container: HTMLElement, mode: DiffViewMode, wrap = false) {
|
||||||
|
if (mode === "unified") {
|
||||||
|
applyCompactUnifiedGutter(container, wrap)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (mode === "split") {
|
||||||
|
applyCompactSplitGutter(container)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ToolCallDiffViewer(props: ToolCallDiffViewerProps) {
|
export function ToolCallDiffViewer(props: ToolCallDiffViewerProps) {
|
||||||
@@ -67,12 +240,15 @@ export function ToolCallDiffViewer(props: ToolCallDiffViewerProps) {
|
|||||||
const contextKey = createMemo(() => {
|
const contextKey = createMemo(() => {
|
||||||
const data = diffData()
|
const data = diffData()
|
||||||
if (!data) return ""
|
if (!data) return ""
|
||||||
return `${props.theme}|${props.mode}|${props.diffText}`
|
return `${props.theme}|${props.mode}|${props.wrap ? "wrap" : "nowrap"}|${props.diffText}`
|
||||||
})
|
})
|
||||||
|
|
||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
const cachedHtml = props.cachedHtml
|
const cachedHtml = props.cachedHtml
|
||||||
if (cachedHtml) {
|
if (cachedHtml) {
|
||||||
|
if (diffContainerRef) {
|
||||||
|
applyCompactDiffLayout(diffContainerRef, props.mode, Boolean(props.wrap))
|
||||||
|
}
|
||||||
// When we are given cached HTML, we rely on the caller's cache
|
// When we are given cached HTML, we rely on the caller's cache
|
||||||
// and simply notify once rendered.
|
// and simply notify once rendered.
|
||||||
props.onRendered?.()
|
props.onRendered?.()
|
||||||
@@ -83,9 +259,10 @@ export function ToolCallDiffViewer(props: ToolCallDiffViewerProps) {
|
|||||||
if (!key) return
|
if (!key) return
|
||||||
if (!diffContainerRef) return
|
if (!diffContainerRef) return
|
||||||
if (lastCapturedKey === key) return
|
if (lastCapturedKey === key) return
|
||||||
|
|
||||||
requestAnimationFrame(() => {
|
requestAnimationFrame(() => {
|
||||||
if (!diffContainerRef) return
|
if (!diffContainerRef) return
|
||||||
|
applyCompactDiffLayout(diffContainerRef, props.mode, Boolean(props.wrap))
|
||||||
const markup = diffContainerRef.innerHTML
|
const markup = diffContainerRef.innerHTML
|
||||||
if (!markup) return
|
if (!markup) return
|
||||||
lastCapturedKey = key
|
lastCapturedKey = key
|
||||||
@@ -95,6 +272,7 @@ export function ToolCallDiffViewer(props: ToolCallDiffViewerProps) {
|
|||||||
html: markup,
|
html: markup,
|
||||||
theme: props.theme,
|
theme: props.theme,
|
||||||
mode: props.mode,
|
mode: props.mode,
|
||||||
|
wrap: props.wrap,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
props.onRendered?.()
|
props.onRendered?.()
|
||||||
@@ -122,7 +300,7 @@ export function ToolCallDiffViewer(props: ToolCallDiffViewerProps) {
|
|||||||
diffViewMode={props.mode === "split" ? DiffModeEnum.Split : DiffModeEnum.Unified}
|
diffViewMode={props.mode === "split" ? DiffModeEnum.Split : DiffModeEnum.Unified}
|
||||||
diffViewTheme={props.theme}
|
diffViewTheme={props.theme}
|
||||||
diffViewHighlight
|
diffViewHighlight
|
||||||
diffViewWrap={false}
|
diffViewWrap={Boolean(props.wrap)}
|
||||||
diffViewFontSize={13}
|
diffViewFontSize={13}
|
||||||
/>
|
/>
|
||||||
</ErrorBoundary>
|
</ErrorBoundary>
|
||||||
@@ -131,7 +309,7 @@ export function ToolCallDiffViewer(props: ToolCallDiffViewerProps) {
|
|||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<div innerHTML={props.cachedHtml} />
|
<div ref={diffContainerRef} innerHTML={props.cachedHtml} />
|
||||||
</Show>
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
|
import { Dialog } from "@kobalte/core/dialog"
|
||||||
import { Select } from "@kobalte/core/select"
|
import { Select } from "@kobalte/core/select"
|
||||||
import { Component, createSignal, Show, For, onMount, onCleanup, createEffect } from "solid-js"
|
import { Component, createSignal, Show, For, onMount, onCleanup, createEffect } from "solid-js"
|
||||||
import { Folder, Clock, Trash2, FolderPlus, Settings, ChevronRight, MonitorUp, Star, Languages, ChevronDown, X } from "lucide-solid"
|
import { Folder, Clock, Trash2, FolderPlus, Settings, ChevronRight, MonitorUp, Star, Languages, ChevronDown, X, Globe, Loader2 } from "lucide-solid"
|
||||||
import { useConfig } from "../stores/preferences"
|
import { useConfig } from "../stores/preferences"
|
||||||
import DirectoryBrowserDialog from "./directory-browser-dialog"
|
import DirectoryBrowserDialog from "./directory-browser-dialog"
|
||||||
import Kbd from "./kbd"
|
import Kbd from "./kbd"
|
||||||
@@ -14,25 +15,48 @@ import { useI18n, type Locale } from "../lib/i18n"
|
|||||||
import { showAlertDialog } from "../stores/alerts"
|
import { showAlertDialog } from "../stores/alerts"
|
||||||
import { openSettings, settingsOpen } from "../stores/settings-screen"
|
import { openSettings, settingsOpen } from "../stores/settings-screen"
|
||||||
import { openExternalUrl } from "../lib/external-url"
|
import { openExternalUrl } from "../lib/external-url"
|
||||||
|
import { serverApi } from "../lib/api-client"
|
||||||
|
import { openRemoteServerWindow } from "../lib/native/remote-window"
|
||||||
|
|
||||||
const codeNomadLogo = new URL("../images/CodeNomad-Icon.png", import.meta.url).href
|
const codeNomadLogo = new URL("../images/CodeNomad-Icon.png", import.meta.url).href
|
||||||
const GITHUB_URL = "https://github.com/NeuralNomadsAI/CodeNomad"
|
const GITHUB_URL = "https://github.com/NeuralNomadsAI/CodeNomad"
|
||||||
const DISCORD_URL = "https://discord.com/channels/1391832426048651334/1458412028325793887/1464701235683917945"
|
const DISCORD_URL = "https://discord.com/channels/1391832426048651334/1458412028325793887/1464701235683917945"
|
||||||
|
|
||||||
|
type HomeTab = "local" | "servers"
|
||||||
|
|
||||||
|
|
||||||
interface FolderSelectionViewProps {
|
interface FolderSelectionViewProps {
|
||||||
onSelectFolder: (folder: string, binaryPath?: string) => void
|
onSelectFolder: (folder: string, binaryPath?: string) => void
|
||||||
|
onOpenSidecar?: () => void
|
||||||
isLoading?: boolean
|
isLoading?: boolean
|
||||||
onClose?: () => void
|
onClose?: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
|
const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
|
||||||
const { recentFolders, removeRecentFolder, preferences, updatePreferences, serverSettings } = useConfig()
|
const {
|
||||||
|
recentFolders,
|
||||||
|
removeRecentFolder,
|
||||||
|
preferences,
|
||||||
|
updatePreferences,
|
||||||
|
serverSettings,
|
||||||
|
remoteServers,
|
||||||
|
saveRemoteServerProfile,
|
||||||
|
markRemoteServerConnected,
|
||||||
|
removeRemoteServerProfile,
|
||||||
|
} = useConfig()
|
||||||
const { t, locale } = useI18n()
|
const { t, locale } = useI18n()
|
||||||
const [selectedIndex, setSelectedIndex] = createSignal(0)
|
const [selectedIndex, setSelectedIndex] = createSignal(0)
|
||||||
const [focusMode, setFocusMode] = createSignal<"recent" | "new" | null>("recent")
|
const [focusMode, setFocusMode] = createSignal<"recent" | "new" | null>("recent")
|
||||||
const [selectedBinary, setSelectedBinary] = createSignal(serverSettings().opencodeBinary || "opencode")
|
const [selectedBinary, setSelectedBinary] = createSignal(serverSettings().opencodeBinary || "opencode")
|
||||||
const [isFolderBrowserOpen, setIsFolderBrowserOpen] = createSignal(false)
|
const [isFolderBrowserOpen, setIsFolderBrowserOpen] = createSignal(false)
|
||||||
|
const [activeTab, setActiveTab] = createSignal<HomeTab>("local")
|
||||||
|
const [isServerDialogOpen, setIsServerDialogOpen] = createSignal(false)
|
||||||
|
const [serverName, setServerName] = createSignal("")
|
||||||
|
const [serverUrl, setServerUrl] = createSignal("")
|
||||||
|
const [skipTlsVerify, setSkipTlsVerify] = createSignal(false)
|
||||||
|
const [serverDialogError, setServerDialogError] = createSignal<string | null>(null)
|
||||||
|
const [isSavingServer, setIsSavingServer] = createSignal(false)
|
||||||
|
const [connectingServerId, setConnectingServerId] = createSignal<string | null>(null)
|
||||||
const nativeDialogsAvailable = supportsNativeDialogs()
|
const nativeDialogsAvailable = supportsNativeDialogs()
|
||||||
let recentListRef: HTMLDivElement | undefined
|
let recentListRef: HTMLDivElement | undefined
|
||||||
|
|
||||||
@@ -49,10 +73,15 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
|
|||||||
]
|
]
|
||||||
|
|
||||||
const selectedLanguageOption = () => languageOptions.find((opt) => opt.value === locale()) ?? languageOptions[0]
|
const selectedLanguageOption = () => languageOptions.find((opt) => opt.value === locale()) ?? languageOptions[0]
|
||||||
|
|
||||||
const folders = () => recentFolders()
|
const folders = () => recentFolders()
|
||||||
|
const serverList = () => remoteServers()
|
||||||
const isLoading = () => Boolean(props.isLoading)
|
const isLoading = () => Boolean(props.isLoading)
|
||||||
|
|
||||||
|
function getActiveListLength() {
|
||||||
|
return activeTab() === "local" ? folders().length : serverList().length
|
||||||
|
}
|
||||||
|
|
||||||
// Update selected binary when preferences change
|
// Update selected binary when preferences change
|
||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
const lastUsed = serverSettings().opencodeBinary
|
const lastUsed = serverSettings().opencodeBinary
|
||||||
@@ -64,7 +93,7 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
|
|||||||
function scrollToIndex(index: number) {
|
function scrollToIndex(index: number) {
|
||||||
const container = recentListRef
|
const container = recentListRef
|
||||||
if (!container) return
|
if (!container) return
|
||||||
const element = container.querySelector(`[data-folder-index="${index}"]`) as HTMLElement | null
|
const element = container.querySelector(`[data-list-index="${index}"]`) as HTMLElement | null
|
||||||
if (!element) return
|
if (!element) return
|
||||||
|
|
||||||
const containerRect = container.getBoundingClientRect()
|
const containerRect = container.getBoundingClientRect()
|
||||||
@@ -113,19 +142,18 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const folderList = folders()
|
|
||||||
|
|
||||||
if (isBrowseShortcut) {
|
if (isBrowseShortcut) {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
void handleBrowse()
|
void handleBrowse()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if (folderList.length === 0) return
|
const listLength = getActiveListLength()
|
||||||
|
if (listLength === 0) return
|
||||||
|
|
||||||
if (e.key === "ArrowDown") {
|
if (e.key === "ArrowDown") {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
const newIndex = Math.min(selectedIndex() + 1, folderList.length - 1)
|
const newIndex = Math.min(selectedIndex() + 1, listLength - 1)
|
||||||
setSelectedIndex(newIndex)
|
setSelectedIndex(newIndex)
|
||||||
setFocusMode("recent")
|
setFocusMode("recent")
|
||||||
scrollToIndex(newIndex)
|
scrollToIndex(newIndex)
|
||||||
@@ -138,7 +166,7 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
|
|||||||
} else if (e.key === "PageDown") {
|
} else if (e.key === "PageDown") {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
const pageSize = 5
|
const pageSize = 5
|
||||||
const newIndex = Math.min(selectedIndex() + pageSize, folderList.length - 1)
|
const newIndex = Math.min(selectedIndex() + pageSize, listLength - 1)
|
||||||
setSelectedIndex(newIndex)
|
setSelectedIndex(newIndex)
|
||||||
setFocusMode("recent")
|
setFocusMode("recent")
|
||||||
scrollToIndex(newIndex)
|
scrollToIndex(newIndex)
|
||||||
@@ -156,7 +184,7 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
|
|||||||
scrollToIndex(0)
|
scrollToIndex(0)
|
||||||
} else if (e.key === "End") {
|
} else if (e.key === "End") {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
const newIndex = folderList.length - 1
|
const newIndex = listLength - 1
|
||||||
setSelectedIndex(newIndex)
|
setSelectedIndex(newIndex)
|
||||||
setFocusMode("recent")
|
setFocusMode("recent")
|
||||||
scrollToIndex(newIndex)
|
scrollToIndex(newIndex)
|
||||||
@@ -165,10 +193,17 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
|
|||||||
handleEnterKey()
|
handleEnterKey()
|
||||||
} else if (e.key === "Backspace" || e.key === "Delete") {
|
} else if (e.key === "Backspace" || e.key === "Delete") {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
if (folderList.length > 0 && focusMode() === "recent") {
|
if (listLength > 0 && focusMode() === "recent") {
|
||||||
const folder = folderList[selectedIndex()]
|
if (activeTab() === "local") {
|
||||||
if (folder) {
|
const folder = folders()[selectedIndex()]
|
||||||
handleRemove(folder.path)
|
if (folder) {
|
||||||
|
handleRemove(folder.path)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const server = serverList()[selectedIndex()]
|
||||||
|
if (server) {
|
||||||
|
removeRemoteServerProfile(server.id)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -177,15 +212,40 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
|
|||||||
|
|
||||||
function handleEnterKey() {
|
function handleEnterKey() {
|
||||||
if (isLoading()) return
|
if (isLoading()) return
|
||||||
const folderList = folders()
|
|
||||||
const index = selectedIndex()
|
const index = selectedIndex()
|
||||||
|
|
||||||
const folder = folderList[index]
|
if (activeTab() === "local") {
|
||||||
if (folder) {
|
const folder = folders()[index]
|
||||||
handleFolderSelect(folder.path)
|
if (folder) {
|
||||||
|
handleFolderSelect(folder.path)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const server = serverList()[index]
|
||||||
|
if (server) {
|
||||||
|
void handleConnectSavedServer(server.id)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
createEffect(() => {
|
||||||
|
activeTab()
|
||||||
|
setSelectedIndex(0)
|
||||||
|
setFocusMode("recent")
|
||||||
|
})
|
||||||
|
|
||||||
|
createEffect(() => {
|
||||||
|
const length = getActiveListLength()
|
||||||
|
if (length === 0) {
|
||||||
|
setSelectedIndex(0)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (selectedIndex() >= length) {
|
||||||
|
setSelectedIndex(length - 1)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
window.addEventListener("keydown", handleKeyDown)
|
window.addEventListener("keydown", handleKeyDown)
|
||||||
@@ -236,6 +296,87 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
|
|||||||
props.onSelectFolder(path, selectedBinary())
|
props.onSelectFolder(path, selectedBinary())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function resetServerDialog() {
|
||||||
|
setServerName("")
|
||||||
|
setServerUrl("")
|
||||||
|
setSkipTlsVerify(false)
|
||||||
|
setServerDialogError(null)
|
||||||
|
}
|
||||||
|
|
||||||
|
function openServerDialog() {
|
||||||
|
resetServerDialog()
|
||||||
|
setIsServerDialogOpen(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function probeAndOpenServer(input: { id?: string; name: string; baseUrl: string; skipTlsVerify: boolean }, openWindow: boolean) {
|
||||||
|
const trimmedName = input.name.trim()
|
||||||
|
const trimmedUrl = input.baseUrl.trim()
|
||||||
|
if (!trimmedName || !trimmedUrl) {
|
||||||
|
throw new Error(t("folderSelection.servers.dialog.errorRequired"))
|
||||||
|
}
|
||||||
|
|
||||||
|
const probe = await serverApi.probeRemoteServer({
|
||||||
|
baseUrl: trimmedUrl,
|
||||||
|
skipTlsVerify: input.skipTlsVerify,
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!probe.ok) {
|
||||||
|
throw new Error(probe.error || t("folderSelection.servers.dialog.errorConnect"))
|
||||||
|
}
|
||||||
|
|
||||||
|
const profile = await saveRemoteServerProfile({
|
||||||
|
id: input.id,
|
||||||
|
name: trimmedName,
|
||||||
|
baseUrl: probe.normalizedUrl,
|
||||||
|
skipTlsVerify: input.skipTlsVerify,
|
||||||
|
})
|
||||||
|
|
||||||
|
if (openWindow) {
|
||||||
|
await openRemoteServerWindow(profile)
|
||||||
|
await markRemoteServerConnected(profile.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
return profile
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSaveServer(openWindow: boolean) {
|
||||||
|
if (isSavingServer()) return
|
||||||
|
setIsSavingServer(true)
|
||||||
|
setServerDialogError(null)
|
||||||
|
try {
|
||||||
|
await probeAndOpenServer(
|
||||||
|
{
|
||||||
|
name: serverName(),
|
||||||
|
baseUrl: serverUrl(),
|
||||||
|
skipTlsVerify: skipTlsVerify(),
|
||||||
|
},
|
||||||
|
openWindow,
|
||||||
|
)
|
||||||
|
setIsServerDialogOpen(false)
|
||||||
|
resetServerDialog()
|
||||||
|
} catch (error) {
|
||||||
|
setServerDialogError(error instanceof Error ? error.message : String(error))
|
||||||
|
} finally {
|
||||||
|
setIsSavingServer(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleConnectSavedServer(id: string) {
|
||||||
|
const target = remoteServers().find((entry) => entry.id === id)
|
||||||
|
if (!target || connectingServerId()) return
|
||||||
|
setConnectingServerId(id)
|
||||||
|
try {
|
||||||
|
await probeAndOpenServer(target, true)
|
||||||
|
} catch (error) {
|
||||||
|
showAlertDialog(error instanceof Error ? error.message : String(error), {
|
||||||
|
title: t("folderSelection.servers.errorTitle"),
|
||||||
|
variant: "warning",
|
||||||
|
})
|
||||||
|
} finally {
|
||||||
|
setConnectingServerId(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function handleBrowse() {
|
async function handleBrowse() {
|
||||||
if (isLoading()) return
|
if (isLoading()) return
|
||||||
setFocusMode("new")
|
setFocusMode("new")
|
||||||
@@ -443,7 +584,7 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
|
|||||||
rel="noreferrer"
|
rel="noreferrer"
|
||||||
class="selector-button selector-button-secondary w-auto px-3 py-1.5 inline-flex items-center justify-center gap-1.5"
|
class="selector-button selector-button-secondary w-auto px-3 py-1.5 inline-flex items-center justify-center gap-1.5"
|
||||||
aria-label={t("folderSelection.links.githubStars")}
|
aria-label={t("folderSelection.links.githubStars")}
|
||||||
title={t("folderSelection.links.githubStars")}
|
title={githubStars() !== null ? `${t("folderSelection.links.githubStars")}: ${githubStars()!.toLocaleString()}` : t("folderSelection.links.githubStars")}
|
||||||
onClick={(event) => {
|
onClick={(event) => {
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
void openExternalUrl(GITHUB_URL, "folder-selection")
|
void openExternalUrl(GITHUB_URL, "folder-selection")
|
||||||
@@ -476,90 +617,223 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
|
|||||||
<div class="flex-1 min-h-0 overflow-hidden flex flex-col lg:flex-row gap-4">
|
<div class="flex-1 min-h-0 overflow-hidden flex flex-col lg:flex-row gap-4">
|
||||||
{/* Right column: recent folders */}
|
{/* Right column: recent folders */}
|
||||||
<div class="order-1 lg:order-2 flex flex-col gap-4 flex-1 min-h-0 overflow-hidden">
|
<div class="order-1 lg:order-2 flex flex-col gap-4 flex-1 min-h-0 overflow-hidden">
|
||||||
<Show
|
|
||||||
when={folders().length > 0}
|
|
||||||
fallback={
|
|
||||||
<div class="panel panel-empty-state flex-1">
|
|
||||||
<div class="panel-empty-state-icon">
|
|
||||||
<Clock class="w-12 h-12 mx-auto" />
|
|
||||||
</div>
|
|
||||||
<p class="panel-empty-state-title">{t("folderSelection.empty.title")}</p>
|
|
||||||
<p class="panel-empty-state-description">{t("folderSelection.empty.description")}</p>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<div class="panel flex flex-col flex-1 min-h-0">
|
<div class="panel flex flex-col flex-1 min-h-0">
|
||||||
<div class="panel-header">
|
<div class="panel-header !gap-0 !p-0">
|
||||||
<h2 class="panel-title">{t("folderSelection.recent.title")}</h2>
|
<div class="grid grid-cols-2 gap-0 overflow-hidden border border-base rounded-t-lg rounded-b-none">
|
||||||
<p class="panel-subtitle">
|
<button
|
||||||
{t(
|
type="button"
|
||||||
folders().length === 1
|
class="border-r border-base px-4 py-3 text-left transition-colors"
|
||||||
? "folderSelection.recent.subtitle.one"
|
classList={{
|
||||||
: "folderSelection.recent.subtitle.other",
|
"text-primary": activeTab() === "local",
|
||||||
{ count: folders().length },
|
"text-muted hover:text-secondary": activeTab() !== "local",
|
||||||
)}
|
}}
|
||||||
</p>
|
style={{
|
||||||
</div>
|
"background-color": "var(--surface-secondary)",
|
||||||
<div
|
}}
|
||||||
class="panel-list panel-list--fill flex-1 min-h-0 overflow-auto"
|
onClick={() => setActiveTab("local")}
|
||||||
ref={(el) => (recentListRef = el)}
|
>
|
||||||
>
|
|
||||||
<For each={folders()}>
|
|
||||||
{(folder, index) => (
|
|
||||||
<div
|
<div
|
||||||
class="panel-list-item"
|
class="panel-title text-base"
|
||||||
classList={{
|
style={{
|
||||||
"panel-list-item-highlight": focusMode() === "recent" && selectedIndex() === index(),
|
color: activeTab() === "local" ? "var(--text-primary)" : "var(--text-secondary)",
|
||||||
"panel-list-item-disabled": isLoading(),
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div class="flex items-center gap-2 w-full px-1">
|
{t("folderSelection.recent.title")}
|
||||||
|
</div>
|
||||||
|
<p
|
||||||
|
class="panel-subtitle mt-1"
|
||||||
|
style={{
|
||||||
|
color: activeTab() === "local" ? "var(--text-muted)" : "var(--text-secondary)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t(
|
||||||
|
folders().length === 1
|
||||||
|
? "folderSelection.recent.subtitle.one"
|
||||||
|
: "folderSelection.recent.subtitle.other",
|
||||||
|
{ count: folders().length },
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="px-4 py-3 text-left transition-colors"
|
||||||
|
classList={{
|
||||||
|
"text-primary": activeTab() === "servers",
|
||||||
|
"text-muted hover:text-secondary": activeTab() !== "servers",
|
||||||
|
}}
|
||||||
|
style={{
|
||||||
|
"background-color": "var(--surface-secondary)",
|
||||||
|
}}
|
||||||
|
onClick={() => setActiveTab("servers")}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="panel-title text-base"
|
||||||
|
style={{
|
||||||
|
color: activeTab() === "servers" ? "var(--text-primary)" : "var(--text-secondary)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t("folderSelection.tabs.servers")}
|
||||||
|
</div>
|
||||||
|
<p
|
||||||
|
class="panel-subtitle mt-1"
|
||||||
|
style={{
|
||||||
|
color: activeTab() === "servers" ? "var(--text-muted)" : "var(--text-secondary)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t("folderSelection.servers.count", { count: remoteServers().length })}
|
||||||
|
</p>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Show
|
||||||
|
when={activeTab() === "local"}
|
||||||
|
fallback={
|
||||||
|
<Show
|
||||||
|
when={remoteServers().length > 0}
|
||||||
|
fallback={
|
||||||
|
<div class="panel-empty-state flex-1">
|
||||||
|
<div class="panel-empty-state-icon">
|
||||||
|
<Globe class="w-12 h-12 mx-auto" />
|
||||||
|
</div>
|
||||||
|
<p class="panel-empty-state-title">{t("folderSelection.servers.empty.title")}</p>
|
||||||
|
<p class="panel-empty-state-description">{t("folderSelection.servers.empty.description")}</p>
|
||||||
<button
|
<button
|
||||||
data-folder-index={index()}
|
type="button"
|
||||||
class="panel-list-item-content flex-1"
|
class="button-primary mt-4 w-auto self-center inline-flex items-center justify-center gap-2 px-4"
|
||||||
disabled={isLoading()}
|
onClick={openServerDialog}
|
||||||
onClick={() => handleFolderSelect(folder.path)}
|
|
||||||
onMouseEnter={() => {
|
|
||||||
if (isLoading()) return
|
|
||||||
setFocusMode("recent")
|
|
||||||
setSelectedIndex(index())
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<div class="flex items-center justify-between gap-3 w-full">
|
<Globe class="w-4 h-4" />
|
||||||
<div class="flex-1 min-w-0">
|
<span>{t("folderSelection.actions.connectButton")}</span>
|
||||||
<div class="flex items-center gap-2 mb-1">
|
|
||||||
<Folder class="w-4 h-4 flex-shrink-0 icon-muted" />
|
|
||||||
<span class="text-sm font-medium truncate text-primary">
|
|
||||||
{splitFolderPath(folder.path).baseName}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div class="flex items-center gap-2 pl-6 text-xs text-muted min-w-0">
|
|
||||||
<span class="font-mono truncate-start flex-1 min-w-0">
|
|
||||||
{getDisplayPath(folder.path)}
|
|
||||||
</span>
|
|
||||||
<span class="flex-shrink-0">{formatRelativeTime(folder.lastAccessed)}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<Show when={focusMode() === "recent" && selectedIndex() === index()}>
|
|
||||||
<kbd class="kbd">↵</kbd>
|
|
||||||
</Show>
|
|
||||||
</div>
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={(e) => handleRemove(folder.path, e)}
|
|
||||||
disabled={isLoading()}
|
|
||||||
class="p-2 transition-all hover:bg-red-100 dark:hover:bg-red-900/30 opacity-70 hover:opacity-100 rounded"
|
|
||||||
title={t("folderSelection.recent.remove")}
|
|
||||||
>
|
|
||||||
<Trash2 class="w-3.5 h-3.5 transition-colors icon-muted hover:text-red-600 dark:hover:text-red-400" />
|
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="panel-list panel-list--fill flex-1 min-h-0 overflow-auto"
|
||||||
|
ref={(el) => (recentListRef = el)}
|
||||||
|
>
|
||||||
|
<For each={remoteServers()}>
|
||||||
|
{(server, index) => (
|
||||||
|
<div
|
||||||
|
class="panel-list-item"
|
||||||
|
classList={{
|
||||||
|
"panel-list-item-highlight": focusMode() === "recent" && selectedIndex() === index(),
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div class="flex items-center gap-2 w-full px-1">
|
||||||
|
<button
|
||||||
|
data-list-index={index()}
|
||||||
|
class="panel-list-item-content flex-1"
|
||||||
|
onClick={() => void handleConnectSavedServer(server.id)}
|
||||||
|
onMouseEnter={() => {
|
||||||
|
setFocusMode("recent")
|
||||||
|
setSelectedIndex(index())
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div class="flex items-center justify-between gap-3 w-full">
|
||||||
|
<div class="flex-1 min-w-0 text-left">
|
||||||
|
<div class="flex items-center gap-2 mb-1">
|
||||||
|
<Globe class="w-4 h-4 flex-shrink-0 icon-muted" />
|
||||||
|
<span class="text-sm font-medium truncate text-primary">{server.name}</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-2 pl-6 text-xs text-muted min-w-0">
|
||||||
|
<span class="font-mono truncate-start flex-1 min-w-0">{server.baseUrl}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Show when={connectingServerId() === server.id} fallback={<Show when={focusMode() === "recent" && selectedIndex() === index()}><kbd class="kbd">↵</kbd></Show>}>
|
||||||
|
<Loader2 class="w-4 h-4 animate-spin icon-muted" />
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => removeRemoteServerProfile(server.id)}
|
||||||
|
class="p-2 transition-all hover:bg-red-100 dark:hover:bg-red-900/30 opacity-70 hover:opacity-100 rounded"
|
||||||
|
title={t("folderSelection.servers.remove")}
|
||||||
|
>
|
||||||
|
<Trash2 class="w-3.5 h-3.5 transition-colors icon-muted hover:text-red-600 dark:hover:text-red-400" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</For>
|
||||||
</div>
|
</div>
|
||||||
)}
|
</Show>
|
||||||
</For>
|
}
|
||||||
</div>
|
>
|
||||||
|
<Show
|
||||||
|
when={folders().length > 0}
|
||||||
|
fallback={
|
||||||
|
<div class="panel-empty-state flex-1">
|
||||||
|
<div class="panel-empty-state-icon">
|
||||||
|
<Clock class="w-12 h-12 mx-auto" />
|
||||||
|
</div>
|
||||||
|
<p class="panel-empty-state-title">{t("folderSelection.empty.title")}</p>
|
||||||
|
<p class="panel-empty-state-description">{t("folderSelection.empty.description")}</p>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="panel-list panel-list--fill flex-1 min-h-0 overflow-auto"
|
||||||
|
ref={(el) => (recentListRef = el)}
|
||||||
|
>
|
||||||
|
<For each={folders()}>
|
||||||
|
{(folder, index) => (
|
||||||
|
<div
|
||||||
|
class="panel-list-item"
|
||||||
|
classList={{
|
||||||
|
"panel-list-item-highlight": focusMode() === "recent" && selectedIndex() === index(),
|
||||||
|
"panel-list-item-disabled": isLoading(),
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div class="flex items-center gap-2 w-full px-1">
|
||||||
|
<button
|
||||||
|
data-list-index={index()}
|
||||||
|
class="panel-list-item-content flex-1"
|
||||||
|
disabled={isLoading()}
|
||||||
|
onClick={() => handleFolderSelect(folder.path)}
|
||||||
|
onMouseEnter={() => {
|
||||||
|
if (isLoading()) return
|
||||||
|
setFocusMode("recent")
|
||||||
|
setSelectedIndex(index())
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div class="flex items-center justify-between gap-3 w-full">
|
||||||
|
<div class="flex-1 min-w-0">
|
||||||
|
<div class="flex items-center gap-2 mb-1">
|
||||||
|
<Folder class="w-4 h-4 flex-shrink-0 icon-muted" />
|
||||||
|
<span class="text-sm font-medium truncate text-primary">
|
||||||
|
{splitFolderPath(folder.path).baseName}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-2 pl-6 text-xs text-muted min-w-0">
|
||||||
|
<span class="font-mono truncate-start flex-1 min-w-0">
|
||||||
|
{getDisplayPath(folder.path)}
|
||||||
|
</span>
|
||||||
|
<span class="flex-shrink-0">{formatRelativeTime(folder.lastAccessed)}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Show when={focusMode() === "recent" && selectedIndex() === index()}>
|
||||||
|
<kbd class="kbd">↵</kbd>
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={(e) => handleRemove(folder.path, e)}
|
||||||
|
disabled={isLoading()}
|
||||||
|
class="p-2 transition-all hover:bg-red-100 dark:hover:bg-red-900/30 opacity-70 hover:opacity-100 rounded"
|
||||||
|
title={t("folderSelection.recent.remove")}
|
||||||
|
>
|
||||||
|
<Trash2 class="w-3.5 h-3.5 transition-colors icon-muted hover:text-red-600 dark:hover:text-red-400" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</For>
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
</Show>
|
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -567,11 +841,11 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
|
|||||||
<div class="order-2 lg:order-1 flex flex-col gap-4 flex-1 min-h-0">
|
<div class="order-2 lg:order-1 flex flex-col gap-4 flex-1 min-h-0">
|
||||||
<div class="panel shrink-0">
|
<div class="panel shrink-0">
|
||||||
<div class="panel-header hidden sm:block">
|
<div class="panel-header hidden sm:block">
|
||||||
<h2 class="panel-title">{t("folderSelection.browse.title")}</h2>
|
<h2 class="panel-title">{t("folderSelection.actions.title")}</h2>
|
||||||
<p class="panel-subtitle">{t("folderSelection.browse.subtitle")}</p>
|
<p class="panel-subtitle">{t("folderSelection.actions.subtitle")}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="panel-body">
|
<div class="panel-body flex flex-col gap-3">
|
||||||
<button
|
<button
|
||||||
onClick={() => void handleBrowse()}
|
onClick={() => void handleBrowse()}
|
||||||
disabled={props.isLoading}
|
disabled={props.isLoading}
|
||||||
@@ -588,6 +862,27 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
|
|||||||
</div>
|
</div>
|
||||||
<Kbd shortcut="cmd+n" class="ml-2 kbd-hint" />
|
<Kbd shortcut="cmd+n" class="ml-2 kbd-hint" />
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => props.onOpenSidecar?.()}
|
||||||
|
class="button-primary mt-3 w-full flex items-center justify-center text-sm"
|
||||||
|
>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<MonitorUp class="w-4 h-4" />
|
||||||
|
<span>{t("folderSelection.sidecars.button")}</span>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={openServerDialog}
|
||||||
|
class="button-primary w-full flex items-center justify-center text-sm"
|
||||||
|
>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<Globe class="w-4 h-4" />
|
||||||
|
<span>{t("folderSelection.actions.connectButton")}</span>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* OpenCode settings section */}
|
{/* OpenCode settings section */}
|
||||||
@@ -663,6 +958,82 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
|
|||||||
onClose={() => setIsFolderBrowserOpen(false)}
|
onClose={() => setIsFolderBrowserOpen(false)}
|
||||||
onSelect={handleBrowserSelect}
|
onSelect={handleBrowserSelect}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<Dialog open={isServerDialogOpen()} onOpenChange={(open) => !open && setIsServerDialogOpen(false)}>
|
||||||
|
<Dialog.Portal>
|
||||||
|
<Dialog.Overlay class="modal-overlay" />
|
||||||
|
<div class="fixed inset-0 z-[1300] flex items-center justify-center p-4">
|
||||||
|
<Dialog.Content class="modal-surface w-full max-w-lg p-6 flex flex-col gap-5" tabIndex={-1}>
|
||||||
|
<div>
|
||||||
|
<Dialog.Title class="text-xl font-semibold text-primary">
|
||||||
|
{t("folderSelection.servers.dialog.title")}
|
||||||
|
</Dialog.Title>
|
||||||
|
<Dialog.Description class="text-sm text-secondary mt-2">
|
||||||
|
{t("folderSelection.servers.dialog.description")}
|
||||||
|
</Dialog.Description>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<label class="flex flex-col gap-2 text-sm text-secondary">
|
||||||
|
<span>{t("folderSelection.servers.dialog.name")}</span>
|
||||||
|
<input
|
||||||
|
class="selector-input w-full"
|
||||||
|
value={serverName()}
|
||||||
|
onInput={(event) => setServerName(event.currentTarget.value)}
|
||||||
|
placeholder={t("folderSelection.servers.dialog.namePlaceholder")}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label class="flex flex-col gap-2 text-sm text-secondary">
|
||||||
|
<span>{t("folderSelection.servers.dialog.url")}</span>
|
||||||
|
<input
|
||||||
|
class="selector-input w-full"
|
||||||
|
value={serverUrl()}
|
||||||
|
onInput={(event) => setServerUrl(event.currentTarget.value)}
|
||||||
|
placeholder={t("folderSelection.servers.dialog.urlPlaceholder")}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label class="flex items-start gap-3 text-sm text-secondary">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={skipTlsVerify()}
|
||||||
|
onChange={(event) => setSkipTlsVerify(event.currentTarget.checked)}
|
||||||
|
/>
|
||||||
|
<span>{t("folderSelection.servers.dialog.skipTls")}</span>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<Show when={serverDialogError()}>
|
||||||
|
{(message) => <p class="text-sm text-red-500 break-words">{message()}</p>}
|
||||||
|
</Show>
|
||||||
|
|
||||||
|
<div class="flex items-center justify-end gap-3">
|
||||||
|
<button class="selector-button selector-button-secondary w-auto px-4" onClick={() => setIsServerDialogOpen(false)}>
|
||||||
|
{t("folderSelection.servers.dialog.cancel")}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="selector-button selector-button-secondary w-auto px-4"
|
||||||
|
disabled={isSavingServer()}
|
||||||
|
onClick={() => void handleSaveServer(false)}
|
||||||
|
>
|
||||||
|
{t("folderSelection.servers.dialog.save")}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="selector-button selector-button-secondary w-auto px-4"
|
||||||
|
disabled={isSavingServer()}
|
||||||
|
onClick={() => void handleSaveServer(true)}
|
||||||
|
>
|
||||||
|
<Show when={isSavingServer()} fallback={<span>{t("folderSelection.servers.dialog.connect")}</span>}>
|
||||||
|
<span class="inline-flex items-center gap-2">
|
||||||
|
<Loader2 class="w-4 h-4 animate-spin" />
|
||||||
|
{t("folderSelection.servers.dialog.connecting")}
|
||||||
|
</span>
|
||||||
|
</Show>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</Dialog.Content>
|
||||||
|
</div>
|
||||||
|
</Dialog.Portal>
|
||||||
|
</Dialog>
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import { Component, For, Show, createMemo } from "solid-js"
|
import { Component, For, Show, createMemo } from "solid-js"
|
||||||
import { Dynamic } from "solid-js/web"
|
import { Dynamic } from "solid-js/web"
|
||||||
import type { Instance } from "../types/instance"
|
|
||||||
import InstanceTab from "./instance-tab"
|
import InstanceTab from "./instance-tab"
|
||||||
import KeyboardHint from "./keyboard-hint"
|
import KeyboardHint from "./keyboard-hint"
|
||||||
import { Plus, MonitorUp, Bell, BellOff, Settings } from "lucide-solid"
|
import { Plus, MonitorUp, Bell, BellOff, Settings } from "lucide-solid"
|
||||||
@@ -9,12 +8,13 @@ import { useI18n } from "../lib/i18n"
|
|||||||
import { isOsNotificationSupportedSync } from "../lib/os-notifications"
|
import { isOsNotificationSupportedSync } from "../lib/os-notifications"
|
||||||
import { useConfig } from "../stores/preferences"
|
import { useConfig } from "../stores/preferences"
|
||||||
import { openSettings } from "../stores/settings-screen"
|
import { openSettings } from "../stores/settings-screen"
|
||||||
|
import type { AppTabRecord } from "../stores/app-tabs"
|
||||||
|
|
||||||
interface InstanceTabsProps {
|
interface InstanceTabsProps {
|
||||||
instances: Map<string, Instance>
|
tabs: AppTabRecord[]
|
||||||
activeInstanceId: string | null
|
activeTabId: string | null
|
||||||
onSelect: (instanceId: string) => void
|
onSelect: (tabId: string) => void
|
||||||
onClose: (instanceId: string) => void
|
onClose: (tabId: string) => void
|
||||||
onNew: () => void
|
onNew: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -42,15 +42,25 @@ const InstanceTabs: Component<InstanceTabsProps> = (props) => {
|
|||||||
<div class="tab-scroll">
|
<div class="tab-scroll">
|
||||||
<div class="tab-strip">
|
<div class="tab-strip">
|
||||||
<div class="tab-strip-tabs">
|
<div class="tab-strip-tabs">
|
||||||
<For each={Array.from(props.instances.entries())}>
|
<For each={props.tabs}>
|
||||||
{([id, instance]) => (
|
{(tab) =>
|
||||||
<InstanceTab
|
tab.kind === "instance" ? (
|
||||||
instance={instance}
|
<InstanceTab
|
||||||
active={id === props.activeInstanceId}
|
instance={tab.instance}
|
||||||
onSelect={() => props.onSelect(id)}
|
active={tab.id === props.activeTabId}
|
||||||
onClose={() => props.onClose(id)}
|
onSelect={() => props.onSelect(tab.id)}
|
||||||
/>
|
onClose={() => props.onClose(tab.id)}
|
||||||
)}
|
/>
|
||||||
|
) : (
|
||||||
|
<div class={`tab-pill ${tab.id === props.activeTabId ? "tab-pill-active" : ""}`}>
|
||||||
|
<button class="tab-pill-button" onClick={() => props.onSelect(tab.id)}>
|
||||||
|
<span class="truncate max-w-[180px]">{tab.sidecarTab.name}</span>
|
||||||
|
</button>
|
||||||
|
<button class="tab-pill-close" onClick={() => props.onClose(tab.id)} aria-label={tab.sidecarTab.name}>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</For>
|
</For>
|
||||||
<button
|
<button
|
||||||
class="new-tab-button"
|
class="new-tab-button"
|
||||||
@@ -62,7 +72,7 @@ const InstanceTabs: Component<InstanceTabsProps> = (props) => {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="tab-strip-spacer" />
|
<div class="tab-strip-spacer" />
|
||||||
<Show when={Array.from(props.instances.entries()).length > 1}>
|
<Show when={props.tabs.length > 1}>
|
||||||
<div class="tab-shortcuts">
|
<div class="tab-shortcuts">
|
||||||
<KeyboardHint
|
<KeyboardHint
|
||||||
shortcuts={[keyboardRegistry.get("instance-prev")!, keyboardRegistry.get("instance-next")!].filter(
|
shortcuts={[keyboardRegistry.get("instance-prev")!, keyboardRegistry.get("instance-next")!].filter(
|
||||||
|
|||||||
@@ -36,12 +36,12 @@ import { serverApi } from "../../lib/api-client"
|
|||||||
import { loadBackgroundProcesses } from "../../stores/background-processes"
|
import { loadBackgroundProcesses } from "../../stores/background-processes"
|
||||||
import { BackgroundProcessOutputDialog } from "../background-process-output-dialog"
|
import { BackgroundProcessOutputDialog } from "../background-process-output-dialog"
|
||||||
import { useI18n } from "../../lib/i18n"
|
import { useI18n } from "../../lib/i18n"
|
||||||
import { getPermissionQueueLength, getQuestionQueueLength } from "../../stores/instances"
|
import { getPermissionQueue, getPermissionQueueLength, getQuestionQueueLength, sendPermissionResponse } from "../../stores/instances"
|
||||||
import SessionSidebar from "./shell/SessionSidebar"
|
import SessionSidebar from "./shell/SessionSidebar"
|
||||||
import { useSessionSidebarRequests } from "./shell/useSessionSidebarRequests"
|
import { useSessionSidebarRequests } from "./shell/useSessionSidebarRequests"
|
||||||
import RightPanel from "./shell/right-panel/RightPanel"
|
import RightPanel from "./shell/right-panel/RightPanel"
|
||||||
import { useDrawerChrome } from "./shell/useDrawerChrome"
|
import { useDrawerChrome } from "./shell/useDrawerChrome"
|
||||||
import { getSessionStatus } from "../../stores/session-status"
|
import { getRetrySeconds, getSessionRetry, getSessionStatus } from "../../stores/session-status"
|
||||||
import { Maximize2, ShieldAlert } from "lucide-solid"
|
import { Maximize2, ShieldAlert } from "lucide-solid"
|
||||||
|
|
||||||
import type { LayoutMode } from "./shell/types"
|
import type { LayoutMode } from "./shell/types"
|
||||||
@@ -57,6 +57,13 @@ import { useDrawerHostMeasure } from "./shell/useDrawerHostMeasure"
|
|||||||
import { useDrawerResize } from "./shell/useDrawerResize"
|
import { useDrawerResize } from "./shell/useDrawerResize"
|
||||||
import { useSessionCache } from "./shell/useSessionCache"
|
import { useSessionCache } from "./shell/useSessionCache"
|
||||||
import { useInstanceSessionContext } from "./shell/useInstanceSessionContext"
|
import { useInstanceSessionContext } from "./shell/useInstanceSessionContext"
|
||||||
|
import { getPermissionSessionId } from "../../types/permission"
|
||||||
|
import {
|
||||||
|
canAutoRespondPermission,
|
||||||
|
finishAutoRespondPermission,
|
||||||
|
getPermissionAutoAcceptInFlightVersion,
|
||||||
|
isPermissionAutoAcceptEnabled,
|
||||||
|
} from "../../stores/permission-auto-accept"
|
||||||
|
|
||||||
const log = getLogger("session")
|
const log = getLogger("session")
|
||||||
|
|
||||||
@@ -97,6 +104,7 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
|
|||||||
const [selectedBackgroundProcess, setSelectedBackgroundProcess] = createSignal<BackgroundProcess | null>(null)
|
const [selectedBackgroundProcess, setSelectedBackgroundProcess] = createSignal<BackgroundProcess | null>(null)
|
||||||
const [showBackgroundOutput, setShowBackgroundOutput] = createSignal(false)
|
const [showBackgroundOutput, setShowBackgroundOutput] = createSignal(false)
|
||||||
const [permissionModalOpen, setPermissionModalOpen] = createSignal(false)
|
const [permissionModalOpen, setPermissionModalOpen] = createSignal(false)
|
||||||
|
const [now, setNow] = createSignal(Date.now())
|
||||||
|
|
||||||
// Worktree selector manages its own dialogs.
|
// Worktree selector manages its own dialogs.
|
||||||
const [showSessionSearch, setShowSessionSearch] = createSignal(false)
|
const [showSessionSearch, setShowSessionSearch] = createSignal(false)
|
||||||
@@ -230,6 +238,12 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
|
|||||||
window.localStorage.setItem(RIGHT_DRAWER_STORAGE_KEY, rightDrawerWidth().toString())
|
window.localStorage.setItem(RIGHT_DRAWER_STORAGE_KEY, rightDrawerWidth().toString())
|
||||||
})
|
})
|
||||||
|
|
||||||
|
createEffect(() => {
|
||||||
|
if (typeof window === "undefined") return
|
||||||
|
const timer = window.setInterval(() => setNow(Date.now()), 1000)
|
||||||
|
onCleanup(() => window.clearInterval(timer))
|
||||||
|
})
|
||||||
|
|
||||||
const connectionStatus = () => sseManager.getStatus(props.instance.id)
|
const connectionStatus = () => sseManager.getStatus(props.instance.id)
|
||||||
const connectionStatusClass = () => {
|
const connectionStatusClass = () => {
|
||||||
const status = connectionStatus()
|
const status = connectionStatus()
|
||||||
@@ -252,6 +266,33 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
|
|||||||
return permissions + questions > 0
|
return permissions + questions > 0
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const permissionQueue = createMemo(() => getPermissionQueue(props.instance.id))
|
||||||
|
|
||||||
|
createEffect(() => {
|
||||||
|
getPermissionAutoAcceptInFlightVersion()
|
||||||
|
|
||||||
|
for (const permission of permissionQueue()) {
|
||||||
|
const sessionId = getPermissionSessionId(permission)
|
||||||
|
if (!sessionId) continue
|
||||||
|
if (!permission?.id) continue
|
||||||
|
if (!canAutoRespondPermission(props.instance.id, sessionId, permission.id)) continue
|
||||||
|
|
||||||
|
void sendPermissionResponse(props.instance.id, sessionId, permission.id, "once")
|
||||||
|
.catch((error) => {
|
||||||
|
log.error("Failed to auto-accept permission", error)
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
finishAutoRespondPermission(props.instance.id, sessionId, permission.id)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const yoloModeEnabled = createMemo(() => {
|
||||||
|
const session = activeSessionForInstance()
|
||||||
|
if (!session) return false
|
||||||
|
return isPermissionAutoAcceptEnabled(props.instance.id, session.id)
|
||||||
|
})
|
||||||
|
|
||||||
const activeSessionStatusPill = createMemo(() => {
|
const activeSessionStatusPill = createMemo(() => {
|
||||||
const activeSessionId = activeSessionIdForInstance()
|
const activeSessionId = activeSessionIdForInstance()
|
||||||
if (!activeSessionId || activeSessionId === "info") return null
|
if (!activeSessionId || activeSessionId === "info") return null
|
||||||
@@ -272,17 +313,28 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const status = getSessionStatus(props.instance.id, activeSessionId)
|
const status = getSessionStatus(props.instance.id, activeSessionId)
|
||||||
const text =
|
const retry = getSessionRetry(props.instance.id, activeSessionId)
|
||||||
status === "working"
|
const text = retry
|
||||||
|
? (() => {
|
||||||
|
const seconds = getRetrySeconds(retry.next, now())
|
||||||
|
return seconds > 0 ? t("sessionList.status.retryingIn", { seconds: String(seconds) }) : t("sessionList.status.retrying")
|
||||||
|
})()
|
||||||
|
: status === "working"
|
||||||
? t("sessionList.status.working")
|
? t("sessionList.status.working")
|
||||||
: status === "compacting"
|
: status === "compacting"
|
||||||
? t("sessionList.status.compacting")
|
? t("sessionList.status.compacting")
|
||||||
: t("sessionList.status.idle")
|
: t("sessionList.status.idle")
|
||||||
|
|
||||||
return {
|
return {
|
||||||
className: `session-${status}`,
|
className: `session-${retry ? "retrying" : status}`,
|
||||||
text,
|
text,
|
||||||
showAlertIcon: false,
|
showAlertIcon: false,
|
||||||
|
title: retry
|
||||||
|
? t("sessionList.status.retryTooltip", {
|
||||||
|
message: retry.message,
|
||||||
|
attempt: String(retry.attempt),
|
||||||
|
})
|
||||||
|
: undefined,
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -290,13 +342,39 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
|
|||||||
const pill = activeSessionStatusPill()
|
const pill = activeSessionStatusPill()
|
||||||
if (!pill) return null
|
if (!pill) return null
|
||||||
return (
|
return (
|
||||||
<span class={`status-indicator session-status session-status-list ${pill.className}`}>
|
<span class={`status-indicator session-status session-status-list ${pill.className}`} title={pill.title}>
|
||||||
{pill.showAlertIcon ? <ShieldAlert class="w-3.5 h-3.5" aria-hidden="true" /> : <span class="status-dot" />}
|
{pill.showAlertIcon ? <ShieldAlert class="w-3.5 h-3.5" aria-hidden="true" /> : <span class="status-dot" />}
|
||||||
{pill.text}
|
{pill.text}
|
||||||
</span>
|
</span>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const renderYoloModePill = () => {
|
||||||
|
if (!yoloModeEnabled()) return null
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
class="status-indicator session-status session-status-list session-yolo-mode"
|
||||||
|
aria-label={t("instanceShell.yoloMode.badgeAriaLabel")}
|
||||||
|
title={t("instanceShell.yoloMode.badgeAriaLabel")}
|
||||||
|
>
|
||||||
|
<span class="status-dot" />
|
||||||
|
{t("instanceShell.yoloMode.badge")}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const renderSessionHeaderIndicators = () => (
|
||||||
|
<div class="flex items-center flex-wrap justify-center gap-2">
|
||||||
|
{renderYoloModePill()}
|
||||||
|
<Show when={hasPendingRequests()} fallback={renderActiveSessionStatusPill()}>
|
||||||
|
<PermissionNotificationBanner
|
||||||
|
instanceId={props.instance.id}
|
||||||
|
onClick={() => setPermissionModalOpen(true)}
|
||||||
|
/>
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
|
||||||
const handleCommandPaletteClick = () => {
|
const handleCommandPaletteClick = () => {
|
||||||
showCommandPalette(props.instance.id)
|
showCommandPalette(props.instance.id)
|
||||||
}
|
}
|
||||||
@@ -622,12 +700,7 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
|
|||||||
</Show>
|
</Show>
|
||||||
|
|
||||||
<div class="flex-1 flex items-center justify-center min-w-0">
|
<div class="flex-1 flex items-center justify-center min-w-0">
|
||||||
<Show when={hasPendingRequests()} fallback={renderActiveSessionStatusPill()}>
|
{renderSessionHeaderIndicators()}
|
||||||
<PermissionNotificationBanner
|
|
||||||
instanceId={props.instance.id}
|
|
||||||
onClick={() => setPermissionModalOpen(true)}
|
|
||||||
/>
|
|
||||||
</Show>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex flex-wrap items-center justify-center gap-1">
|
<div class="flex flex-wrap items-center justify-center gap-1">
|
||||||
@@ -719,12 +792,7 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
|
|||||||
</Show>
|
</Show>
|
||||||
|
|
||||||
<div class="ml-auto flex items-center session-header-hints">
|
<div class="ml-auto flex items-center session-header-hints">
|
||||||
<Show when={hasPendingRequests()} fallback={renderActiveSessionStatusPill()}>
|
{renderSessionHeaderIndicators()}
|
||||||
<PermissionNotificationBanner
|
|
||||||
instanceId={props.instance.id}
|
|
||||||
onClick={() => setPermissionModalOpen(true)}
|
|
||||||
/>
|
|
||||||
</Show>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -48,104 +48,103 @@ interface SessionSidebarProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const SessionSidebar: Component<SessionSidebarProps> = (props) => (
|
const SessionSidebar: Component<SessionSidebarProps> = (props) => (
|
||||||
<div class="flex flex-col h-full min-h-0" ref={props.setContentEl}>
|
<div class="flex flex-col h-full min-h-0" ref={props.setContentEl}>
|
||||||
<div class="flex flex-col gap-2 px-4 py-3 border-b border-base">
|
<div class="flex flex-col gap-2 px-4 py-3 border-b border-base">
|
||||||
<div class="flex items-center justify-between gap-2">
|
<div class="flex items-center justify-between gap-2">
|
||||||
<span class="session-sidebar-title text-sm font-semibold uppercase text-primary">
|
<span class="session-sidebar-title text-sm font-semibold uppercase text-primary">
|
||||||
{props.t("instanceShell.leftPanel.sessionsTitle")}
|
{props.t("instanceShell.leftPanel.sessionsTitle")}
|
||||||
</span>
|
</span>
|
||||||
<div class="flex items-center gap-2 text-primary">
|
<div class="flex items-center gap-2 text-primary">
|
||||||
<IconButton
|
|
||||||
size="small"
|
|
||||||
color="inherit"
|
|
||||||
aria-label={props.t("sessionList.actions.newSession.ariaLabel")}
|
|
||||||
title={props.t("sessionList.actions.newSession.title")}
|
|
||||||
onClick={() => {
|
|
||||||
const result = props.onNewSession()
|
|
||||||
if (result instanceof Promise) {
|
|
||||||
void result.catch((error) => log.error("Failed to create session:", error))
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<PlusSquare class="w-5 h-5" />
|
|
||||||
</IconButton>
|
|
||||||
<IconButton
|
|
||||||
size="small"
|
|
||||||
color="inherit"
|
|
||||||
aria-label={props.t("sessionList.filter.ariaLabel")}
|
|
||||||
title={props.t("sessionList.filter.ariaLabel")}
|
|
||||||
aria-pressed={props.showSearch()}
|
|
||||||
onClick={props.onToggleSearch}
|
|
||||||
sx={{
|
|
||||||
color: props.showSearch() ? "var(--text-primary)" : "inherit",
|
|
||||||
backgroundColor: props.showSearch() ? "var(--surface-hover)" : "transparent",
|
|
||||||
"&:hover": {
|
|
||||||
backgroundColor: "var(--surface-hover)",
|
|
||||||
},
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Search class="w-5 h-5" />
|
|
||||||
</IconButton>
|
|
||||||
<IconButton
|
|
||||||
size="small"
|
|
||||||
color="inherit"
|
|
||||||
aria-label={props.t("instanceShell.leftPanel.instanceInfo")}
|
|
||||||
title={props.t("instanceShell.leftPanel.instanceInfo")}
|
|
||||||
onClick={() => props.onSelectSession("info")}
|
|
||||||
>
|
|
||||||
<InfoOutlinedIcon fontSize="small" />
|
|
||||||
</IconButton>
|
|
||||||
<Show when={!props.isPhoneLayout()}>
|
|
||||||
<IconButton
|
<IconButton
|
||||||
size="small"
|
size="small"
|
||||||
color="inherit"
|
color="inherit"
|
||||||
aria-label={props.leftPinned() ? props.t("instanceShell.leftDrawer.unpin") : props.t("instanceShell.leftDrawer.pin")}
|
aria-label={props.t("sessionList.actions.newSession.ariaLabel")}
|
||||||
onClick={() => (props.leftPinned() ? props.onUnpinLeftDrawer() : props.onPinLeftDrawer())}
|
title={props.t("sessionList.actions.newSession.title")}
|
||||||
|
onClick={() => {
|
||||||
|
const result = props.onNewSession()
|
||||||
|
if (result instanceof Promise) {
|
||||||
|
void result.catch((error) => log.error("Failed to create session:", error))
|
||||||
|
}
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
{props.leftPinned() ? <PushPinIcon fontSize="small" /> : <PushPinOutlinedIcon fontSize="small" />}
|
<PlusSquare class="w-5 h-5" />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
</Show>
|
|
||||||
<Show when={props.drawerState() === "floating-open"}>
|
|
||||||
<IconButton
|
<IconButton
|
||||||
size="small"
|
size="small"
|
||||||
color="inherit"
|
color="inherit"
|
||||||
aria-label={props.t("instanceShell.leftDrawer.toggle.close")}
|
aria-label={props.t("sessionList.filter.ariaLabel")}
|
||||||
title={props.t("instanceShell.leftDrawer.toggle.close")}
|
title={props.t("sessionList.filter.ariaLabel")}
|
||||||
onClick={props.onCloseLeftDrawer}
|
aria-pressed={props.showSearch()}
|
||||||
|
onClick={props.onToggleSearch}
|
||||||
|
sx={{
|
||||||
|
color: props.showSearch() ? "var(--text-primary)" : "inherit",
|
||||||
|
backgroundColor: props.showSearch() ? "var(--surface-hover)" : "transparent",
|
||||||
|
"&:hover": {
|
||||||
|
backgroundColor: "var(--surface-hover)",
|
||||||
|
},
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<MenuOpenIcon fontSize="small" />
|
<Search class="w-5 h-5" />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
|
<IconButton
|
||||||
|
size="small"
|
||||||
|
color="inherit"
|
||||||
|
aria-label={props.t("instanceShell.leftPanel.instanceInfo")}
|
||||||
|
title={props.t("instanceShell.leftPanel.instanceInfo")}
|
||||||
|
onClick={() => props.onSelectSession("info")}
|
||||||
|
>
|
||||||
|
<InfoOutlinedIcon fontSize="small" />
|
||||||
|
</IconButton>
|
||||||
|
<Show when={!props.isPhoneLayout()}>
|
||||||
|
<IconButton
|
||||||
|
size="small"
|
||||||
|
color="inherit"
|
||||||
|
aria-label={props.leftPinned() ? props.t("instanceShell.leftDrawer.unpin") : props.t("instanceShell.leftDrawer.pin")}
|
||||||
|
onClick={() => (props.leftPinned() ? props.onUnpinLeftDrawer() : props.onPinLeftDrawer())}
|
||||||
|
>
|
||||||
|
{props.leftPinned() ? <PushPinIcon fontSize="small" /> : <PushPinOutlinedIcon fontSize="small" />}
|
||||||
|
</IconButton>
|
||||||
|
</Show>
|
||||||
|
<Show when={props.drawerState() === "floating-open"}>
|
||||||
|
<IconButton
|
||||||
|
size="small"
|
||||||
|
color="inherit"
|
||||||
|
aria-label={props.t("instanceShell.leftDrawer.toggle.close")}
|
||||||
|
title={props.t("instanceShell.leftDrawer.toggle.close")}
|
||||||
|
onClick={props.onCloseLeftDrawer}
|
||||||
|
>
|
||||||
|
<MenuOpenIcon fontSize="small" />
|
||||||
|
</IconButton>
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="session-sidebar-shortcuts">
|
||||||
|
<Show when={props.keyboardShortcuts().length}>
|
||||||
|
<KeyboardHint shortcuts={props.keyboardShortcuts()} separator=" " showDescription={false} />
|
||||||
</Show>
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="session-sidebar-shortcuts">
|
|
||||||
<Show when={props.keyboardShortcuts().length}>
|
|
||||||
<KeyboardHint shortcuts={props.keyboardShortcuts()} separator=" " showDescription={false} />
|
|
||||||
</Show>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="session-sidebar flex flex-col flex-1 min-h-0">
|
<div class="session-sidebar flex flex-col flex-1 min-h-0">
|
||||||
<SessionList
|
<SessionList
|
||||||
instanceId={props.instanceId}
|
instanceId={props.instanceId}
|
||||||
threads={props.threads()}
|
threads={props.threads()}
|
||||||
activeSessionId={props.activeSessionId()}
|
activeSessionId={props.activeSessionId()}
|
||||||
onSelect={props.onSelectSession}
|
onSelect={props.onSelectSession}
|
||||||
onNew={() => {
|
onNew={() => {
|
||||||
const result = props.onNewSession()
|
const result = props.onNewSession()
|
||||||
if (result instanceof Promise) {
|
if (result instanceof Promise) {
|
||||||
void result.catch((error) => log.error("Failed to create session:", error))
|
void result.catch((error) => log.error("Failed to create session:", error))
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
enableFilterBar={props.showSearch()}
|
enableFilterBar={props.showSearch()}
|
||||||
showHeader={false}
|
showHeader={false}
|
||||||
showFooter={false}
|
showFooter={false}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div class="session-sidebar-separator" />
|
<div class="session-sidebar-separator" />
|
||||||
<Show when={props.activeSession()}>
|
<Show when={props.activeSession()}>
|
||||||
{(activeSession) => (
|
{(activeSession) => (
|
||||||
<>
|
|
||||||
<div class="session-sidebar-controls px-4 py-4 border-t border-base flex flex-col gap-3">
|
<div class="session-sidebar-controls px-4 py-4 border-t border-base flex flex-col gap-3">
|
||||||
<WorktreeSelector instanceId={props.instanceId} sessionId={activeSession().id} />
|
<WorktreeSelector instanceId={props.instanceId} sessionId={activeSession().id} />
|
||||||
|
|
||||||
@@ -177,11 +176,10 @@ const SessionSidebar: Component<SessionSidebarProps> = (props) => (
|
|||||||
showDescription={false}
|
showDescription={false}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</>
|
)}
|
||||||
)}
|
</Show>
|
||||||
</Show>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
)
|
||||||
)
|
|
||||||
|
|
||||||
export default SessionSidebar
|
export default SessionSidebar
|
||||||
|
|||||||
@@ -89,6 +89,7 @@ interface RightPanelProps {
|
|||||||
const RightPanel: Component<RightPanelProps> = (props) => {
|
const RightPanel: Component<RightPanelProps> = (props) => {
|
||||||
const [rightPanelTab, setRightPanelTab] = createSignal<RightPanelTab>(readStoredRightPanelTab("changes"))
|
const [rightPanelTab, setRightPanelTab] = createSignal<RightPanelTab>(readStoredRightPanelTab("changes"))
|
||||||
const [rightPanelExpandedItems, setRightPanelExpandedItems] = createSignal<string[]>([
|
const [rightPanelExpandedItems, setRightPanelExpandedItems] = createSignal<string[]>([
|
||||||
|
"yolo-mode",
|
||||||
"plan",
|
"plan",
|
||||||
"background-processes",
|
"background-processes",
|
||||||
"mcp",
|
"mcp",
|
||||||
@@ -787,7 +788,7 @@ const RightPanel: Component<RightPanelProps> = (props) => {
|
|||||||
setRightPanelTab("changes")
|
setRightPanelTab("changes")
|
||||||
}
|
}
|
||||||
|
|
||||||
const statusSectionIds = ["session-changes", "plan", "background-processes", "mcp", "lsp", "plugins"]
|
const statusSectionIds = ["yolo-mode", "session-changes", "plan", "background-processes", "mcp", "lsp", "plugins"]
|
||||||
|
|
||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
const currentExpanded = new Set(rightPanelExpandedItems())
|
const currentExpanded = new Set(rightPanelExpandedItems())
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { For, Show, type Accessor, type Component } from "solid-js"
|
|||||||
import type { ToolState } from "@opencode-ai/sdk/v2"
|
import type { ToolState } from "@opencode-ai/sdk/v2"
|
||||||
import { Accordion } from "@kobalte/core"
|
import { Accordion } from "@kobalte/core"
|
||||||
import { Tooltip } from "@kobalte/core/tooltip"
|
import { Tooltip } from "@kobalte/core/tooltip"
|
||||||
|
import Switch from "@suid/material/Switch"
|
||||||
|
|
||||||
import { ChevronDown, Info, TerminalSquare, Trash2, XOctagon } from "lucide-solid"
|
import { ChevronDown, Info, TerminalSquare, Trash2, XOctagon } from "lucide-solid"
|
||||||
|
|
||||||
@@ -12,6 +13,7 @@ import type { Session } from "../../../../../types/session"
|
|||||||
import ContextUsagePanel from "../../../../session/context-usage-panel"
|
import ContextUsagePanel from "../../../../session/context-usage-panel"
|
||||||
import { TodoListView } from "../../../../tool-call/renderers/todo"
|
import { TodoListView } from "../../../../tool-call/renderers/todo"
|
||||||
import InstanceServiceStatus from "../../../../instance-service-status"
|
import InstanceServiceStatus from "../../../../instance-service-status"
|
||||||
|
import { isPermissionAutoAcceptEnabled, togglePermissionAutoAccept } from "../../../../../stores/permission-auto-accept"
|
||||||
|
|
||||||
interface StatusTabProps {
|
interface StatusTabProps {
|
||||||
t: (key: string, vars?: Record<string, any>) => string
|
t: (key: string, vars?: Record<string, any>) => string
|
||||||
@@ -39,6 +41,35 @@ interface StatusTabProps {
|
|||||||
const StatusTab: Component<StatusTabProps> = (props) => {
|
const StatusTab: Component<StatusTabProps> = (props) => {
|
||||||
const isSectionExpanded = (id: string) => props.expandedItems().includes(id)
|
const isSectionExpanded = (id: string) => props.expandedItems().includes(id)
|
||||||
|
|
||||||
|
const renderYoloModeSection = () => {
|
||||||
|
const session = props.activeSession()
|
||||||
|
if (!session) {
|
||||||
|
return (
|
||||||
|
<div class="right-panel-empty right-panel-empty--left">
|
||||||
|
<span class="text-xs">{props.t("instanceShell.yoloMode.noSessionSelected")}</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div class="rounded-md border border-base bg-surface-secondary px-3 py-2">
|
||||||
|
<div class="flex items-start justify-between gap-3">
|
||||||
|
<div class="min-w-0">
|
||||||
|
<div class="text-sm font-medium text-primary">{props.t("instanceShell.yoloMode.title")}</div>
|
||||||
|
<p class="mt-1 text-xs text-secondary">{props.t("instanceShell.yoloMode.description")}</p>
|
||||||
|
</div>
|
||||||
|
<Switch
|
||||||
|
checked={isPermissionAutoAcceptEnabled(props.instanceId, session.id)}
|
||||||
|
color="warning"
|
||||||
|
size="small"
|
||||||
|
inputProps={{ "aria-label": props.t("instanceShell.yoloMode.title") }}
|
||||||
|
onChange={() => togglePermissionAutoAccept(props.instanceId, session.id)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
const renderStatusSessionChanges = () => {
|
const renderStatusSessionChanges = () => {
|
||||||
const sessionId = props.activeSessionId()
|
const sessionId = props.activeSessionId()
|
||||||
if (!sessionId || sessionId === "info") {
|
if (!sessionId || sessionId === "info") {
|
||||||
@@ -204,6 +235,12 @@ const StatusTab: Component<StatusTabProps> = (props) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const statusSections = [
|
const statusSections = [
|
||||||
|
{
|
||||||
|
id: "yolo-mode",
|
||||||
|
labelKey: "instanceShell.rightPanel.sections.yoloMode",
|
||||||
|
tooltipKey: "instanceShell.rightPanel.sections.yoloMode.tooltip",
|
||||||
|
render: renderYoloModeSection,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
id: "session-changes",
|
id: "session-changes",
|
||||||
labelKey: "instanceShell.rightPanel.sections.sessionChanges",
|
labelKey: "instanceShell.rightPanel.sections.sessionChanges",
|
||||||
@@ -281,29 +318,23 @@ const StatusTab: Component<StatusTabProps> = (props) => {
|
|||||||
<For each={statusSections}>
|
<For each={statusSections}>
|
||||||
{(section) => (
|
{(section) => (
|
||||||
<Accordion.Item value={section.id} class="right-panel-accordion-item">
|
<Accordion.Item value={section.id} class="right-panel-accordion-item">
|
||||||
<Accordion.Header>
|
<Accordion.Header class="right-panel-accordion-header-row">
|
||||||
<Accordion.Trigger class="right-panel-accordion-trigger">
|
<Accordion.Trigger class="right-panel-accordion-trigger">
|
||||||
<span class="section-left">
|
<span class="section-left">
|
||||||
<Tooltip openDelay={200} gutter={4} placement="top">
|
|
||||||
<Tooltip.Trigger
|
|
||||||
class="section-info-trigger"
|
|
||||||
aria-label={props.t(section.tooltipKey)}
|
|
||||||
onClick={(e) => e.stopPropagation()}
|
|
||||||
>
|
|
||||||
<Info class="section-info-icon" />
|
|
||||||
</Tooltip.Trigger>
|
|
||||||
<Tooltip.Portal>
|
|
||||||
<Tooltip.Content class="section-info-tooltip">
|
|
||||||
{props.t(section.tooltipKey)}
|
|
||||||
</Tooltip.Content>
|
|
||||||
</Tooltip.Portal>
|
|
||||||
</Tooltip>
|
|
||||||
<span class="section-label">{props.t(section.labelKey)}</span>
|
<span class="section-label">{props.t(section.labelKey)}</span>
|
||||||
</span>
|
</span>
|
||||||
<ChevronDown
|
<ChevronDown
|
||||||
class={`right-panel-accordion-chevron ${isSectionExpanded(section.id) ? "right-panel-accordion-chevron-expanded" : ""}`}
|
class={`right-panel-accordion-chevron ${isSectionExpanded(section.id) ? "right-panel-accordion-chevron-expanded" : ""}`}
|
||||||
/>
|
/>
|
||||||
</Accordion.Trigger>
|
</Accordion.Trigger>
|
||||||
|
<Tooltip openDelay={200} gutter={4} placement="top">
|
||||||
|
<Tooltip.Trigger as="button" type="button" class="section-info-trigger" aria-label={props.t(section.tooltipKey)}>
|
||||||
|
<Info class="section-info-icon" />
|
||||||
|
</Tooltip.Trigger>
|
||||||
|
<Tooltip.Portal>
|
||||||
|
<Tooltip.Content class="section-info-tooltip">{props.t(section.tooltipKey)}</Tooltip.Content>
|
||||||
|
</Tooltip.Portal>
|
||||||
|
</Tooltip>
|
||||||
</Accordion.Header>
|
</Accordion.Header>
|
||||||
<Accordion.Content class="right-panel-accordion-content">{section.render()}</Accordion.Content>
|
<Accordion.Content class="right-panel-accordion-content">{section.render()}</Accordion.Content>
|
||||||
</Accordion.Item>
|
</Accordion.Item>
|
||||||
|
|||||||
@@ -123,7 +123,11 @@ export function Markdown(props: MarkdownProps) {
|
|||||||
version: () => resolved().version,
|
version: () => resolved().version,
|
||||||
})
|
})
|
||||||
|
|
||||||
const commitCacheEntry = (snapshot: ReturnType<typeof resolved>, renderedHtml: string) => {
|
const commitCacheEntry = (
|
||||||
|
snapshot: ReturnType<typeof resolved>,
|
||||||
|
renderedHtml: string,
|
||||||
|
options?: { cache?: boolean },
|
||||||
|
) => {
|
||||||
const cacheEntry: RenderCache = {
|
const cacheEntry: RenderCache = {
|
||||||
text: snapshot.text,
|
text: snapshot.text,
|
||||||
html: renderedHtml,
|
html: renderedHtml,
|
||||||
@@ -131,7 +135,9 @@ export function Markdown(props: MarkdownProps) {
|
|||||||
mode: `${snapshot.version}:${snapshot.escapeRawHtml ? "escaped" : "raw"}`,
|
mode: `${snapshot.version}:${snapshot.escapeRawHtml ? "escaped" : "raw"}`,
|
||||||
}
|
}
|
||||||
setHtml(renderedHtml)
|
setHtml(renderedHtml)
|
||||||
cacheHandle.set(cacheEntry)
|
if (options?.cache ?? true) {
|
||||||
|
cacheHandle.set(cacheEntry)
|
||||||
|
}
|
||||||
notifyRendered()
|
notifyRendered()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -142,9 +148,10 @@ export function Markdown(props: MarkdownProps) {
|
|||||||
suppressHighlight: !snapshot.highlightEnabled,
|
suppressHighlight: !snapshot.highlightEnabled,
|
||||||
escapeRawHtml: snapshot.escapeRawHtml,
|
escapeRawHtml: snapshot.escapeRawHtml,
|
||||||
})
|
})
|
||||||
|
const shouldCache = !snapshot.highlightEnabled || !markdown.hasPendingCodeHighlight(snapshot.text)
|
||||||
|
|
||||||
if (latestRequestKey === snapshot.requestKey) {
|
if (latestRequestKey === snapshot.requestKey) {
|
||||||
commitCacheEntry(snapshot, rendered)
|
commitCacheEntry(snapshot, rendered, { cache: shouldCache })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import type { MessageRecord } from "../stores/message-v2/types"
|
|||||||
import { messageStoreBus } from "../stores/message-v2/bus"
|
import { messageStoreBus } from "../stores/message-v2/bus"
|
||||||
import { formatTokenTotal } from "../lib/formatters"
|
import { formatTokenTotal } from "../lib/formatters"
|
||||||
import { sessions, setActiveParentSession, setActiveSession } from "../stores/sessions"
|
import { sessions, setActiveParentSession, setActiveSession } from "../stores/sessions"
|
||||||
import { setActiveInstanceId } from "../stores/instances"
|
import { selectInstanceTab } from "../stores/app-tabs"
|
||||||
import { showAlertDialog } from "../stores/alerts"
|
import { showAlertDialog } from "../stores/alerts"
|
||||||
import { deleteMessage } from "../stores/session-actions"
|
import { deleteMessage } from "../stores/session-actions"
|
||||||
import { useI18n } from "../lib/i18n"
|
import { useI18n } from "../lib/i18n"
|
||||||
@@ -130,7 +130,7 @@ function findTaskSessionLocation(sessionId: string, preferredInstanceId?: string
|
|||||||
}
|
}
|
||||||
|
|
||||||
function navigateToTaskSession(location: TaskSessionLocation) {
|
function navigateToTaskSession(location: TaskSessionLocation) {
|
||||||
setActiveInstanceId(location.instanceId)
|
selectInstanceTab(location.instanceId)
|
||||||
const parentToActivate = location.parentId ?? location.sessionId
|
const parentToActivate = location.parentId ?? location.sessionId
|
||||||
setActiveParentSession(location.instanceId, parentToActivate)
|
setActiveParentSession(location.instanceId, parentToActivate)
|
||||||
if (location.parentId) {
|
if (location.parentId) {
|
||||||
|
|||||||
@@ -19,7 +19,12 @@ import { usePromptAttachments } from "./prompt-input/usePromptAttachments"
|
|||||||
import { usePromptPicker } from "./prompt-input/usePromptPicker"
|
import { usePromptPicker } from "./prompt-input/usePromptPicker"
|
||||||
import { usePromptKeyDown } from "./prompt-input/usePromptKeyDown"
|
import { usePromptKeyDown } from "./prompt-input/usePromptKeyDown"
|
||||||
import { usePromptVoiceInput } from "./prompt-input/usePromptVoiceInput"
|
import { usePromptVoiceInput } from "./prompt-input/usePromptVoiceInput"
|
||||||
import { canUseConversationMode, isConversationModeEnabled, toggleConversationMode } from "../stores/conversation-speech"
|
import {
|
||||||
|
canUseConversationMode,
|
||||||
|
clearConversationPlaybackForInstance,
|
||||||
|
isConversationModeEnabled,
|
||||||
|
toggleConversationMode,
|
||||||
|
} from "../stores/conversation-speech"
|
||||||
const log = getLogger("actions")
|
const log = getLogger("actions")
|
||||||
const LazyUnifiedPicker = lazy(() => import("./unified-picker"))
|
const LazyUnifiedPicker = lazy(() => import("./unified-picker"))
|
||||||
|
|
||||||
@@ -492,6 +497,8 @@ export default function PromptInput(props: PromptInputProps) {
|
|||||||
const beginVoicePress = (event?: PointerEvent | KeyboardEvent) => {
|
const beginVoicePress = (event?: PointerEvent | KeyboardEvent) => {
|
||||||
if (voiceButtonPressed || props.disabled || voiceInput.isTranscribing() || !voiceInput.canUseVoiceInput()) return
|
if (voiceButtonPressed || props.disabled || voiceInput.isTranscribing() || !voiceInput.canUseVoiceInput()) return
|
||||||
voiceButtonPressed = true
|
voiceButtonPressed = true
|
||||||
|
// Treat a mic press as barge-in: stop any active assistant speech before listening.
|
||||||
|
clearConversationPlaybackForInstance(props.instanceId)
|
||||||
|
|
||||||
if (event instanceof PointerEvent) {
|
if (event instanceof PointerEvent) {
|
||||||
const target = event.currentTarget
|
const target = event.currentTarget
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { Dialog } from "@kobalte/core/dialog"
|
|||||||
import { Switch } from "@kobalte/core/switch"
|
import { Switch } from "@kobalte/core/switch"
|
||||||
import { For, Show, createEffect, createMemo, createSignal } from "solid-js"
|
import { For, Show, createEffect, createMemo, createSignal } from "solid-js"
|
||||||
import { toDataURL } from "qrcode"
|
import { toDataURL } from "qrcode"
|
||||||
import { ExternalLink, Link2, Loader2, RefreshCw, Shield, Wifi } from "lucide-solid"
|
import { ChevronDown, ExternalLink, Link2, Loader2, RefreshCw, Shield, Wifi } from "lucide-solid"
|
||||||
import type { NetworkAddress, ServerMeta } from "../../../server/src/api-types"
|
import type { NetworkAddress, ServerMeta } from "../../../server/src/api-types"
|
||||||
import { serverApi } from "../lib/api-client"
|
import { serverApi } from "../lib/api-client"
|
||||||
import { restartCli } from "../lib/native/cli"
|
import { restartCli } from "../lib/native/cli"
|
||||||
@@ -10,6 +10,7 @@ import { serverSettings, setListeningMode } from "../stores/preferences"
|
|||||||
import { showConfirmDialog } from "../stores/alerts"
|
import { showConfirmDialog } from "../stores/alerts"
|
||||||
import { getLogger } from "../lib/logger"
|
import { getLogger } from "../lib/logger"
|
||||||
import { useI18n } from "../lib/i18n"
|
import { useI18n } from "../lib/i18n"
|
||||||
|
import { splitRemoteAddresses, type RemoteAddressGroups } from "../lib/remote-access-addresses"
|
||||||
const log = getLogger("actions")
|
const log = getLogger("actions")
|
||||||
|
|
||||||
|
|
||||||
@@ -32,17 +33,17 @@ export function RemoteAccessOverlay(props: RemoteAccessOverlayProps) {
|
|||||||
const [passwordConfirm, setPasswordConfirm] = createSignal("")
|
const [passwordConfirm, setPasswordConfirm] = createSignal("")
|
||||||
const [passwordError, setPasswordError] = createSignal<string | null>(null)
|
const [passwordError, setPasswordError] = createSignal<string | null>(null)
|
||||||
const [savingPassword, setSavingPassword] = createSignal(false)
|
const [savingPassword, setSavingPassword] = createSignal(false)
|
||||||
|
const [showAllAddresses, setShowAllAddresses] = createSignal(false)
|
||||||
|
|
||||||
const addresses = createMemo<NetworkAddress[]>(() => meta()?.addresses ?? [])
|
const addresses = createMemo<NetworkAddress[]>(() => meta()?.addresses ?? [])
|
||||||
const currentMode = createMemo(() => meta()?.listeningMode ?? serverSettings().listeningMode)
|
const currentMode = createMemo(() => meta()?.listeningMode ?? serverSettings().listeningMode)
|
||||||
const allowExternalConnections = createMemo(() => currentMode() === "all")
|
const allowExternalConnections = createMemo(() => currentMode() === "all")
|
||||||
const displayAddresses = createMemo(() => {
|
const displayAddresses = createMemo<RemoteAddressGroups>(() => {
|
||||||
const list = addresses()
|
const list = addresses()
|
||||||
if (!allowExternalConnections()) {
|
if (!allowExternalConnections()) {
|
||||||
return []
|
return { recommended: null, hidden: [] }
|
||||||
}
|
}
|
||||||
// Local URL is displayed separately; list only remote-friendly addresses.
|
return splitRemoteAddresses(list)
|
||||||
return list.filter((address) => address.scope !== "loopback")
|
|
||||||
})
|
})
|
||||||
|
|
||||||
const refreshMeta = async () => {
|
const refreshMeta = async () => {
|
||||||
@@ -53,6 +54,7 @@ export function RemoteAccessOverlay(props: RemoteAccessOverlayProps) {
|
|||||||
const [metaResult, authResult] = await Promise.all([serverApi.fetchServerMeta(), serverApi.fetchAuthStatus()])
|
const [metaResult, authResult] = await Promise.all([serverApi.fetchServerMeta(), serverApi.fetchAuthStatus()])
|
||||||
setMeta(metaResult)
|
setMeta(metaResult)
|
||||||
setAuthStatus(authResult)
|
setAuthStatus(authResult)
|
||||||
|
setShowAllAddresses(false)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err instanceof Error ? err.message : String(err))
|
setError(err instanceof Error ? err.message : String(err))
|
||||||
} finally {
|
} finally {
|
||||||
@@ -326,7 +328,7 @@ export function RemoteAccessOverlay(props: RemoteAccessOverlayProps) {
|
|||||||
|
|
||||||
<Show when={!loading()} fallback={<div class="remote-card">{t("remoteAccess.addresses.loading")}</div>}>
|
<Show when={!loading()} fallback={<div class="remote-card">{t("remoteAccess.addresses.loading")}</div>}>
|
||||||
<Show when={!error()} fallback={<div class="remote-error">{error()}</div>}>
|
<Show when={!error()} fallback={<div class="remote-error">{error()}</div>}>
|
||||||
<Show when={displayAddresses().length > 0} fallback={<div class="remote-card">{t("remoteAccess.addresses.none")}</div>}>
|
<Show when={displayAddresses().recommended || meta()?.localUrl} fallback={<div class="remote-card">{t("remoteAccess.addresses.none")}</div>}>
|
||||||
<div class="remote-address-list">
|
<div class="remote-address-list">
|
||||||
<Show when={meta()?.localUrl}>
|
<Show when={meta()?.localUrl}>
|
||||||
{(url) => {
|
{(url) => {
|
||||||
@@ -373,8 +375,9 @@ export function RemoteAccessOverlay(props: RemoteAccessOverlayProps) {
|
|||||||
)
|
)
|
||||||
}}
|
}}
|
||||||
</Show>
|
</Show>
|
||||||
<For each={displayAddresses()}>
|
<Show when={displayAddresses().recommended}>
|
||||||
{(address) => {
|
{(addressAccessor) => {
|
||||||
|
const address = addressAccessor()
|
||||||
const url = address.remoteUrl
|
const url = address.remoteUrl
|
||||||
const expandedState = () => expandedUrl() === url
|
const expandedState = () => expandedUrl() === url
|
||||||
const qr = () => qrCodes()[url]
|
const qr = () => qrCodes()[url]
|
||||||
@@ -384,13 +387,14 @@ export function RemoteAccessOverlay(props: RemoteAccessOverlayProps) {
|
|||||||
: address.scope === "loopback"
|
: address.scope === "loopback"
|
||||||
? t("remoteAccess.address.scope.loopback")
|
? t("remoteAccess.address.scope.loopback")
|
||||||
: t("remoteAccess.address.scope.internal")
|
: t("remoteAccess.address.scope.internal")
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div class="remote-address">
|
<div class="remote-address">
|
||||||
<div class="remote-address-main">
|
<div class="remote-address-main">
|
||||||
<div>
|
<div>
|
||||||
<p class="remote-address-url">{url}</p>
|
<p class="remote-address-url">{url}</p>
|
||||||
<p class="remote-address-meta">
|
<p class="remote-address-meta">
|
||||||
{address.family.toUpperCase()} • {scopeLabel()} • {address.ip}
|
{address.family.toUpperCase()} - {scopeLabel()} - {address.ip}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="remote-actions">
|
<div class="remote-actions">
|
||||||
@@ -425,7 +429,83 @@ export function RemoteAccessOverlay(props: RemoteAccessOverlayProps) {
|
|||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}}
|
}}
|
||||||
</For>
|
</Show>
|
||||||
|
|
||||||
|
<Show when={displayAddresses().hidden.length > 0}>
|
||||||
|
<div class="remote-address-disclosure" data-expanded={showAllAddresses()}>
|
||||||
|
<button
|
||||||
|
class="remote-address-disclosure-trigger"
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShowAllAddresses(!showAllAddresses())}
|
||||||
|
aria-expanded={showAllAddresses()}
|
||||||
|
>
|
||||||
|
<span class="remote-address-disclosure-label">
|
||||||
|
{showAllAddresses()
|
||||||
|
? t("remoteAccess.addresses.actions.hideOther")
|
||||||
|
: t("remoteAccess.addresses.actions.showOther", { count: String(displayAddresses().hidden.length) })}
|
||||||
|
</span>
|
||||||
|
<ChevronDown class={`remote-address-disclosure-chevron ${showAllAddresses() ? "is-expanded" : ""}`} />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<Show when={showAllAddresses()}>
|
||||||
|
<div class="remote-address-disclosure-content">
|
||||||
|
<For each={displayAddresses().hidden}>
|
||||||
|
{(address) => {
|
||||||
|
const url = address.remoteUrl
|
||||||
|
const expandedState = () => expandedUrl() === url
|
||||||
|
const qr = () => qrCodes()[url]
|
||||||
|
const scopeLabel = () =>
|
||||||
|
address.scope === "external"
|
||||||
|
? t("remoteAccess.address.scope.network")
|
||||||
|
: address.scope === "loopback"
|
||||||
|
? t("remoteAccess.address.scope.loopback")
|
||||||
|
: t("remoteAccess.address.scope.internal")
|
||||||
|
return (
|
||||||
|
<div class="remote-address">
|
||||||
|
<div class="remote-address-main">
|
||||||
|
<div>
|
||||||
|
<p class="remote-address-url">{url}</p>
|
||||||
|
<p class="remote-address-meta">
|
||||||
|
{address.family.toUpperCase()} • {scopeLabel()} • {address.ip}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="remote-actions">
|
||||||
|
<button class="remote-pill" type="button" onClick={() => handleOpenUrl(url)}>
|
||||||
|
<ExternalLink class="remote-icon" />
|
||||||
|
{t("remoteAccess.address.open")}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="remote-pill"
|
||||||
|
type="button"
|
||||||
|
onClick={() => void toggleExpanded(url)}
|
||||||
|
aria-expanded={expandedState()}
|
||||||
|
>
|
||||||
|
<Link2 class="remote-icon" />
|
||||||
|
{expandedState() ? t("remoteAccess.address.hideQr") : t("remoteAccess.address.showQr")}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Show when={expandedState()}>
|
||||||
|
<div class="remote-qr">
|
||||||
|
<Show when={qr()} fallback={<Loader2 class="remote-icon remote-spin" aria-hidden="true" />}>
|
||||||
|
{(dataUrl) => (
|
||||||
|
<img
|
||||||
|
src={dataUrl()}
|
||||||
|
alt={t("remoteAccess.address.qrAlt", { url })}
|
||||||
|
class="remote-qr-img"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
</For>
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
</Show>
|
</Show>
|
||||||
</Show>
|
</Show>
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import { Component, For, Show, createSignal, createMemo, createEffect, JSX, onCleanup } from "solid-js"
|
import { Component, For, Show, createSignal, createMemo, createEffect, JSX, onCleanup } from "solid-js"
|
||||||
import type { SessionStatus } from "../types/session"
|
import type { SessionStatus } from "../types/session"
|
||||||
import type { SessionThread } from "../stores/session-state"
|
import type { SessionThread } from "../stores/session-state"
|
||||||
import { getSessionStatus } from "../stores/session-status"
|
import { getRetrySeconds, getSessionRetry, getSessionStatus } from "../stores/session-status"
|
||||||
import { Bot, User, Copy, Trash2, Pencil, ShieldAlert, ChevronDown, Search, Square, CheckSquare, MinusSquare, Split } from "lucide-solid"
|
import { Bot, User, Copy, Trash2, Pencil, ShieldAlert, ChevronDown, Search, Square, CheckSquare, MinusSquare, Split, RotateCw } from "lucide-solid"
|
||||||
import KeyboardHint from "./keyboard-hint"
|
import KeyboardHint from "./keyboard-hint"
|
||||||
import SessionRenameDialog from "./session-rename-dialog"
|
import SessionRenameDialog from "./session-rename-dialog"
|
||||||
import { keyboardRegistry } from "../lib/keyboard-registry"
|
import { keyboardRegistry } from "../lib/keyboard-registry"
|
||||||
@@ -14,6 +14,7 @@ import {
|
|||||||
ensureSessionParentExpanded,
|
ensureSessionParentExpanded,
|
||||||
getVisibleSessionIds,
|
getVisibleSessionIds,
|
||||||
isSessionParentExpanded,
|
isSessionParentExpanded,
|
||||||
|
loadMessages,
|
||||||
loading,
|
loading,
|
||||||
renameSession,
|
renameSession,
|
||||||
sessions as sessionStateSessions,
|
sessions as sessionStateSessions,
|
||||||
@@ -53,6 +54,14 @@ const SessionList: Component<SessionListProps> = (props) => {
|
|||||||
const normalizedQuery = createMemo(() => (props.enableFilterBar ? filterQuery().trim().toLowerCase() : ""))
|
const normalizedQuery = createMemo(() => (props.enableFilterBar ? filterQuery().trim().toLowerCase() : ""))
|
||||||
|
|
||||||
const [selectedSessionIds, setSelectedSessionIds] = createSignal<Set<string>>(new Set())
|
const [selectedSessionIds, setSelectedSessionIds] = createSignal<Set<string>>(new Set())
|
||||||
|
const [reloadingSessionIds, setReloadingSessionIds] = createSignal<Set<string>>(new Set())
|
||||||
|
const [now, setNow] = createSignal(Date.now())
|
||||||
|
|
||||||
|
createEffect(() => {
|
||||||
|
if (typeof window === "undefined") return
|
||||||
|
const timer = window.setInterval(() => setNow(Date.now()), 1000)
|
||||||
|
onCleanup(() => window.clearInterval(timer))
|
||||||
|
})
|
||||||
|
|
||||||
const normalizeSessionLabel = (sessionId: string) => {
|
const normalizeSessionLabel = (sessionId: string) => {
|
||||||
const session = sessionStateSessions().get(props.instanceId)?.get(sessionId)
|
const session = sessionStateSessions().get(props.instanceId)?.get(sessionId)
|
||||||
@@ -213,6 +222,32 @@ const SessionList: Component<SessionListProps> = (props) => {
|
|||||||
setRenameTarget({ id: sessionId, title: session.title ?? "", label })
|
setRenameTarget({ id: sessionId, title: session.title ?? "", label })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const isSessionReloading = (sessionId: string) => reloadingSessionIds().has(sessionId)
|
||||||
|
|
||||||
|
const handleReloadSession = async (event: MouseEvent, sessionId: string) => {
|
||||||
|
event.stopPropagation()
|
||||||
|
if (isSessionReloading(sessionId)) return
|
||||||
|
|
||||||
|
setReloadingSessionIds((prev) => {
|
||||||
|
const next = new Set(prev)
|
||||||
|
next.add(sessionId)
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
|
||||||
|
try {
|
||||||
|
await loadMessages(props.instanceId, sessionId, true)
|
||||||
|
} catch (error) {
|
||||||
|
log.error(`Failed to reload session ${sessionId}:`, error)
|
||||||
|
showToastNotification({ message: t("sessionList.reload.error"), variant: "error" })
|
||||||
|
} finally {
|
||||||
|
setReloadingSessionIds((prev) => {
|
||||||
|
const next = new Set(prev)
|
||||||
|
next.delete(sessionId)
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const closeRenameDialog = () => {
|
const closeRenameDialog = () => {
|
||||||
setRenameTarget(null)
|
setRenameTarget(null)
|
||||||
}
|
}
|
||||||
@@ -372,7 +407,13 @@ const SessionList: Component<SessionListProps> = (props) => {
|
|||||||
const isActive = () => props.activeSessionId === rowProps.sessionId
|
const isActive = () => props.activeSessionId === rowProps.sessionId
|
||||||
const title = () => session()?.title || t("sessionList.session.untitled")
|
const title = () => session()?.title || t("sessionList.session.untitled")
|
||||||
const status = () => getSessionStatus(props.instanceId, rowProps.sessionId)
|
const status = () => getSessionStatus(props.instanceId, rowProps.sessionId)
|
||||||
|
const retry = () => getSessionRetry(props.instanceId, rowProps.sessionId)
|
||||||
const statusLabel = () => {
|
const statusLabel = () => {
|
||||||
|
const retryState = retry()
|
||||||
|
if (retryState) {
|
||||||
|
const seconds = getRetrySeconds(retryState.next, now())
|
||||||
|
return seconds > 0 ? t("sessionList.status.retryingIn", { seconds: String(seconds) }) : t("sessionList.status.retrying")
|
||||||
|
}
|
||||||
switch (formatSessionStatus(status())) {
|
switch (formatSessionStatus(status())) {
|
||||||
case "working":
|
case "working":
|
||||||
return t("sessionList.status.working")
|
return t("sessionList.status.working")
|
||||||
@@ -385,13 +426,21 @@ const SessionList: Component<SessionListProps> = (props) => {
|
|||||||
const needsPermission = () => Boolean(session()?.pendingPermission)
|
const needsPermission = () => Boolean(session()?.pendingPermission)
|
||||||
const needsQuestion = () => Boolean((session() as any)?.pendingQuestion)
|
const needsQuestion = () => Boolean((session() as any)?.pendingQuestion)
|
||||||
const needsInput = () => needsPermission() || needsQuestion()
|
const needsInput = () => needsPermission() || needsQuestion()
|
||||||
const statusClassName = () => (needsInput() ? "session-permission" : `session-${status()}`)
|
const statusClassName = () => (needsInput() ? "session-permission" : `session-${retry() ? "retrying" : status()}`)
|
||||||
const statusText = () =>
|
const statusText = () =>
|
||||||
needsPermission()
|
needsPermission()
|
||||||
? t("sessionList.status.needsPermission")
|
? t("sessionList.status.needsPermission")
|
||||||
: needsQuestion()
|
: needsQuestion()
|
||||||
? t("sessionList.status.needsInput")
|
? t("sessionList.status.needsInput")
|
||||||
: statusLabel()
|
: statusLabel()
|
||||||
|
const statusTooltip = () => {
|
||||||
|
const retryState = retry()
|
||||||
|
if (!retryState) return undefined
|
||||||
|
return t("sessionList.status.retryTooltip", {
|
||||||
|
message: retryState.message,
|
||||||
|
attempt: String(retryState.attempt),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
const isSelected = () => selectedSessionIds().has(rowProps.sessionId)
|
const isSelected = () => selectedSessionIds().has(rowProps.sessionId)
|
||||||
|
|
||||||
@@ -471,7 +520,7 @@ const SessionList: Component<SessionListProps> = (props) => {
|
|||||||
<ChevronDown class={`w-3.5 h-3.5 transition-transform ${rowProps.expanded ? "" : "-rotate-90"}`} />
|
<ChevronDown class={`w-3.5 h-3.5 transition-transform ${rowProps.expanded ? "" : "-rotate-90"}`} />
|
||||||
</span>
|
</span>
|
||||||
</Show>
|
</Show>
|
||||||
<span class={`status-indicator session-status session-status-list ${statusClassName()}`}>
|
<span class={`status-indicator session-status session-status-list ${statusClassName()}`} title={statusTooltip()}>
|
||||||
{needsInput() ? <ShieldAlert class="w-3.5 h-3.5" aria-hidden="true" /> : <span class="status-dot" />}
|
{needsInput() ? <ShieldAlert class="w-3.5 h-3.5" aria-hidden="true" /> : <span class="status-dot" />}
|
||||||
{statusText()}
|
{statusText()}
|
||||||
</span>
|
</span>
|
||||||
@@ -493,6 +542,21 @@ const SessionList: Component<SessionListProps> = (props) => {
|
|||||||
>
|
>
|
||||||
<Copy class="w-3 h-3" />
|
<Copy class="w-3 h-3" />
|
||||||
</span>
|
</span>
|
||||||
|
<span
|
||||||
|
class={`session-item-close opacity-80 hover:opacity-100 ${isActive() ? "hover:bg-white/20" : "hover:bg-surface-hover"}`}
|
||||||
|
onClick={(event) => handleReloadSession(event, rowProps.sessionId)}
|
||||||
|
role="button"
|
||||||
|
tabIndex={0}
|
||||||
|
aria-label={t("sessionList.actions.reload.ariaLabel")}
|
||||||
|
title={t("sessionList.actions.reload.title")}
|
||||||
|
>
|
||||||
|
<Show
|
||||||
|
when={!isSessionReloading(rowProps.sessionId)}
|
||||||
|
fallback={<RotateCw class="w-3 h-3 animate-spin" />}
|
||||||
|
>
|
||||||
|
<RotateCw class="w-3 h-3" />
|
||||||
|
</Show>
|
||||||
|
</span>
|
||||||
<span
|
<span
|
||||||
class={`session-item-close opacity-80 hover:opacity-100 ${isActive() ? "hover:bg-white/20" : "hover:bg-surface-hover"}`}
|
class={`session-item-close opacity-80 hover:opacity-100 ${isActive() ? "hover:bg-white/20" : "hover:bg-surface-hover"}`}
|
||||||
onClick={(event) => {
|
onClick={(event) => {
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { Dialog } from "@kobalte/core/dialog"
|
import { Dialog } from "@kobalte/core/dialog"
|
||||||
import { Settings, Bell, MonitorUp, Paintbrush, Terminal, Volume2, X } from "lucide-solid"
|
import { Settings, Bell, MonitorUp, Paintbrush, Terminal, Volume2, Globe, X } from "lucide-solid"
|
||||||
import { createMemo, For, type Component } from "solid-js"
|
import { createMemo, For, type Component } from "solid-js"
|
||||||
import { useI18n } from "../lib/i18n"
|
import { useI18n } from "../lib/i18n"
|
||||||
import {
|
import {
|
||||||
@@ -14,6 +14,7 @@ import { NotificationsSettingsSection } from "./settings/notifications-settings-
|
|||||||
import { OpenCodeSettingsSection } from "./settings/opencode-settings-section"
|
import { OpenCodeSettingsSection } from "./settings/opencode-settings-section"
|
||||||
import { RemoteAccessSettingsSection } from "./settings/remote-access-settings-section"
|
import { RemoteAccessSettingsSection } from "./settings/remote-access-settings-section"
|
||||||
import { SpeechSettingsSection } from "./settings/speech-settings-section"
|
import { SpeechSettingsSection } from "./settings/speech-settings-section"
|
||||||
|
import { SideCarsSettingsSection } from "./settings/sidecars-settings-section"
|
||||||
|
|
||||||
export const SettingsScreen: Component = () => {
|
export const SettingsScreen: Component = () => {
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
@@ -23,6 +24,7 @@ export const SettingsScreen: Component = () => {
|
|||||||
{ id: "notifications" as SettingsSectionId, icon: Bell, label: t("settings.nav.notifications") },
|
{ id: "notifications" as SettingsSectionId, icon: Bell, label: t("settings.nav.notifications") },
|
||||||
{ id: "remote" as SettingsSectionId, icon: MonitorUp, label: t("settings.nav.remote") },
|
{ id: "remote" as SettingsSectionId, icon: MonitorUp, label: t("settings.nav.remote") },
|
||||||
{ id: "speech" as SettingsSectionId, icon: Volume2, label: t("settings.nav.speech") },
|
{ id: "speech" as SettingsSectionId, icon: Volume2, label: t("settings.nav.speech") },
|
||||||
|
{ id: "sidecars" as SettingsSectionId, icon: Globe, label: t("settings.nav.sidecars") },
|
||||||
{ id: "opencode" as SettingsSectionId, icon: Terminal, label: t("settings.nav.opencode") },
|
{ id: "opencode" as SettingsSectionId, icon: Terminal, label: t("settings.nav.opencode") },
|
||||||
])
|
])
|
||||||
|
|
||||||
@@ -34,6 +36,8 @@ export const SettingsScreen: Component = () => {
|
|||||||
return <RemoteAccessSettingsSection />
|
return <RemoteAccessSettingsSection />
|
||||||
case "speech":
|
case "speech":
|
||||||
return <SpeechSettingsSection />
|
return <SpeechSettingsSection />
|
||||||
|
case "sidecars":
|
||||||
|
return <SideCarsSettingsSection />
|
||||||
case "opencode":
|
case "opencode":
|
||||||
return <OpenCodeSettingsSection />
|
return <OpenCodeSettingsSection />
|
||||||
case "appearance":
|
case "appearance":
|
||||||
|
|||||||
@@ -1,14 +1,30 @@
|
|||||||
import { createEffect, createSignal, type Component } from "solid-js"
|
import { Select } from "@kobalte/core/select"
|
||||||
import { Terminal } from "lucide-solid"
|
import { createEffect, createMemo, createSignal, type Component } from "solid-js"
|
||||||
|
import { ChevronDown, Terminal } from "lucide-solid"
|
||||||
import OpenCodeBinarySelector from "../opencode-binary-selector"
|
import OpenCodeBinarySelector from "../opencode-binary-selector"
|
||||||
import EnvironmentVariablesEditor from "../environment-variables-editor"
|
import EnvironmentVariablesEditor from "../environment-variables-editor"
|
||||||
import { useConfig } from "../../stores/preferences"
|
import { useConfig } from "../../stores/preferences"
|
||||||
|
import type { ServerLogLevel } from "../../stores/preferences"
|
||||||
import { useI18n } from "../../lib/i18n"
|
import { useI18n } from "../../lib/i18n"
|
||||||
|
|
||||||
|
type LogLevelOption = {
|
||||||
|
value: ServerLogLevel
|
||||||
|
label: string
|
||||||
|
}
|
||||||
|
|
||||||
export const OpenCodeSettingsSection: Component = () => {
|
export const OpenCodeSettingsSection: Component = () => {
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
const { serverSettings, updateLastUsedBinary } = useConfig()
|
const { serverSettings, updateLastUsedBinary, updateLogLevel } = useConfig()
|
||||||
const [selectedBinary, setSelectedBinary] = createSignal(serverSettings().opencodeBinary || "opencode")
|
const [selectedBinary, setSelectedBinary] = createSignal(serverSettings().opencodeBinary || "opencode")
|
||||||
|
const logLevelOptions = createMemo<LogLevelOption[]>(() => [
|
||||||
|
{ value: "DEBUG", label: t("settings.opencode.logLevel.option.debug") },
|
||||||
|
{ value: "INFO", label: t("settings.opencode.logLevel.option.info") },
|
||||||
|
{ value: "WARN", label: t("settings.opencode.logLevel.option.warn") },
|
||||||
|
{ value: "ERROR", label: t("settings.opencode.logLevel.option.error") },
|
||||||
|
])
|
||||||
|
const selectedLogLevel = createMemo(
|
||||||
|
() => logLevelOptions().find((option) => option.value === serverSettings().logLevel) ?? logLevelOptions()[0],
|
||||||
|
)
|
||||||
|
|
||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
const binary = serverSettings().opencodeBinary || "opencode"
|
const binary = serverSettings().opencodeBinary || "opencode"
|
||||||
@@ -37,6 +53,60 @@ export const OpenCodeSettingsSection: Component = () => {
|
|||||||
<OpenCodeBinarySelector selectedBinary={selectedBinary()} onBinaryChange={handleBinaryChange} isVisible />
|
<OpenCodeBinarySelector selectedBinary={selectedBinary()} onBinaryChange={handleBinaryChange} isVisible />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="settings-card">
|
||||||
|
<div class="settings-card-header">
|
||||||
|
<div>
|
||||||
|
<h3 class="settings-card-title">{t("settings.opencode.logLevel.title")}</h3>
|
||||||
|
<p class="settings-card-subtitle">{t("settings.opencode.logLevel.subtitle")}</p>
|
||||||
|
</div>
|
||||||
|
<span class="settings-scope-badge settings-scope-badge-server">{t("settings.scope.server")}</span>
|
||||||
|
</div>
|
||||||
|
<div class="settings-card-body">
|
||||||
|
<div class="settings-toggle-row settings-toggle-row-compact">
|
||||||
|
<div>
|
||||||
|
<div class="settings-toggle-title">{t("settings.opencode.logLevel.selector.title")}</div>
|
||||||
|
<div class="settings-toggle-caption">{t("settings.opencode.logLevel.selector.subtitle")}</div>
|
||||||
|
</div>
|
||||||
|
<Select<LogLevelOption>
|
||||||
|
value={selectedLogLevel()}
|
||||||
|
onChange={(option) => {
|
||||||
|
if (!option) return
|
||||||
|
updateLogLevel(option.value)
|
||||||
|
}}
|
||||||
|
options={logLevelOptions()}
|
||||||
|
optionValue="value"
|
||||||
|
optionTextValue="label"
|
||||||
|
itemComponent={(itemProps) => (
|
||||||
|
<Select.Item item={itemProps.item} class="selector-option">
|
||||||
|
<Select.ItemLabel class="selector-option-label">{itemProps.item.rawValue.label}</Select.ItemLabel>
|
||||||
|
</Select.Item>
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Select.Trigger class="selector-trigger" aria-label={t("settings.opencode.logLevel.title")}>
|
||||||
|
<div class="flex-1 min-w-0">
|
||||||
|
<Select.Value<LogLevelOption>>
|
||||||
|
{(state) => (
|
||||||
|
<span class="selector-trigger-primary selector-trigger-primary--align-left">
|
||||||
|
{state.selectedOption()?.label}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</Select.Value>
|
||||||
|
</div>
|
||||||
|
<Select.Icon class="selector-trigger-icon">
|
||||||
|
<ChevronDown class="w-3 h-3" />
|
||||||
|
</Select.Icon>
|
||||||
|
</Select.Trigger>
|
||||||
|
|
||||||
|
<Select.Portal>
|
||||||
|
<Select.Content class="selector-popover">
|
||||||
|
<Select.Listbox class="selector-listbox" />
|
||||||
|
</Select.Content>
|
||||||
|
</Select.Portal>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="settings-card">
|
<div class="settings-card">
|
||||||
<div class="settings-card-header">
|
<div class="settings-card-header">
|
||||||
<div>
|
<div>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { Switch } from "@kobalte/core/switch"
|
import { Switch } from "@kobalte/core/switch"
|
||||||
import { For, Show, createMemo, createSignal, type Component, onMount } from "solid-js"
|
import { For, Show, createMemo, createSignal, type Component, onMount } from "solid-js"
|
||||||
import { toDataURL } from "qrcode"
|
import { toDataURL } from "qrcode"
|
||||||
import { ExternalLink, Link2, Loader2, RefreshCw, Shield, Wifi } from "lucide-solid"
|
import { ChevronDown, ExternalLink, Link2, Loader2, RefreshCw, Shield, Wifi } from "lucide-solid"
|
||||||
import type { NetworkAddress, ServerMeta } from "../../../../server/src/api-types"
|
import type { NetworkAddress, ServerMeta } from "../../../../server/src/api-types"
|
||||||
import { serverApi } from "../../lib/api-client"
|
import { serverApi } from "../../lib/api-client"
|
||||||
import { restartCli } from "../../lib/native/cli"
|
import { restartCli } from "../../lib/native/cli"
|
||||||
@@ -9,6 +9,7 @@ import { serverSettings, setListeningMode } from "../../stores/preferences"
|
|||||||
import { showConfirmDialog } from "../../stores/alerts"
|
import { showConfirmDialog } from "../../stores/alerts"
|
||||||
import { getLogger } from "../../lib/logger"
|
import { getLogger } from "../../lib/logger"
|
||||||
import { useI18n } from "../../lib/i18n"
|
import { useI18n } from "../../lib/i18n"
|
||||||
|
import { splitRemoteAddresses, type RemoteAddressGroups } from "../../lib/remote-access-addresses"
|
||||||
|
|
||||||
const log = getLogger("actions")
|
const log = getLogger("actions")
|
||||||
|
|
||||||
@@ -30,14 +31,15 @@ export const RemoteAccessSettingsSection: Component = () => {
|
|||||||
const [passwordConfirm, setPasswordConfirm] = createSignal("")
|
const [passwordConfirm, setPasswordConfirm] = createSignal("")
|
||||||
const [passwordError, setPasswordError] = createSignal<string | null>(null)
|
const [passwordError, setPasswordError] = createSignal<string | null>(null)
|
||||||
const [savingPassword, setSavingPassword] = createSignal(false)
|
const [savingPassword, setSavingPassword] = createSignal(false)
|
||||||
|
const [showAllAddresses, setShowAllAddresses] = createSignal(false)
|
||||||
|
|
||||||
const addresses = createMemo<NetworkAddress[]>(() => meta()?.addresses ?? [])
|
const addresses = createMemo<NetworkAddress[]>(() => meta()?.addresses ?? [])
|
||||||
const currentMode = createMemo(() => meta()?.listeningMode ?? serverSettings().listeningMode)
|
const currentMode = createMemo(() => meta()?.listeningMode ?? serverSettings().listeningMode)
|
||||||
const allowExternalConnections = createMemo(() => currentMode() === "all")
|
const allowExternalConnections = createMemo(() => currentMode() === "all")
|
||||||
const displayAddresses = createMemo(() => {
|
const displayAddresses = createMemo<RemoteAddressGroups>(() => {
|
||||||
const list = addresses()
|
const list = addresses()
|
||||||
if (!allowExternalConnections()) return []
|
if (!allowExternalConnections()) return { recommended: null, hidden: [] }
|
||||||
return list.filter((address) => address.scope !== "loopback")
|
return splitRemoteAddresses(list)
|
||||||
})
|
})
|
||||||
|
|
||||||
const refreshMeta = async () => {
|
const refreshMeta = async () => {
|
||||||
@@ -48,6 +50,7 @@ export const RemoteAccessSettingsSection: Component = () => {
|
|||||||
const [metaResult, authResult] = await Promise.all([serverApi.fetchServerMeta(), serverApi.fetchAuthStatus()])
|
const [metaResult, authResult] = await Promise.all([serverApi.fetchServerMeta(), serverApi.fetchAuthStatus()])
|
||||||
setMeta(metaResult)
|
setMeta(metaResult)
|
||||||
setAuthStatus(authResult)
|
setAuthStatus(authResult)
|
||||||
|
setShowAllAddresses(false)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err instanceof Error ? err.message : String(err))
|
setError(err instanceof Error ? err.message : String(err))
|
||||||
} finally {
|
} finally {
|
||||||
@@ -218,31 +221,35 @@ export const RemoteAccessSettingsSection: Component = () => {
|
|||||||
fallback={<div class="settings-card-message">{t("remoteAccess.authStatus.unavailable")}</div>}
|
fallback={<div class="settings-card-message">{t("remoteAccess.authStatus.unavailable")}</div>}
|
||||||
>
|
>
|
||||||
<div class="settings-card-content">
|
<div class="settings-card-content">
|
||||||
<p class="settings-help-text">{t("remoteAccess.username", { username: authStatus()!.username ?? "codenomad" })}</p>
|
<div class="settings-password-summary-row">
|
||||||
<p class="settings-help-text">
|
<div class="settings-password-summary-copy">
|
||||||
{authStatus()!.passwordUserProvided
|
<p class="settings-help-text">{t("remoteAccess.username", { username: authStatus()!.username ?? "codenomad" })}</p>
|
||||||
? t("remoteAccess.password.status.set")
|
<p class="settings-help-text">
|
||||||
: t("remoteAccess.password.status.unset")}
|
{authStatus()!.passwordUserProvided
|
||||||
</p>
|
? t("remoteAccess.password.status.set")
|
||||||
|
: t("remoteAccess.password.status.unset")}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="settings-password-actions">
|
<div class="settings-password-actions">
|
||||||
<button
|
<button
|
||||||
class="settings-pill-button"
|
class="settings-pill-button"
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setPasswordFormOpen(!passwordFormOpen())
|
setPasswordFormOpen(!passwordFormOpen())
|
||||||
setPasswordError(null)
|
setPasswordError(null)
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{passwordFormOpen()
|
{passwordFormOpen()
|
||||||
? t("remoteAccess.password.actions.cancel")
|
? t("remoteAccess.password.actions.cancel")
|
||||||
: authStatus()!.passwordUserProvided
|
: authStatus()!.passwordUserProvided
|
||||||
? t("remoteAccess.password.actions.change")
|
? t("remoteAccess.password.actions.change")
|
||||||
: t("remoteAccess.password.actions.set")}
|
: t("remoteAccess.password.actions.set")}
|
||||||
</button>
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Show when={passwordFormOpen()}>
|
<Show when={passwordFormOpen()}>
|
||||||
<div class="settings-form-group">
|
<div class="settings-form-group">
|
||||||
<label class="settings-form-label">{t("remoteAccess.password.form.newPassword")}</label>
|
<label class="settings-form-label">{t("remoteAccess.password.form.newPassword")}</label>
|
||||||
<input
|
<input
|
||||||
@@ -292,7 +299,7 @@ export const RemoteAccessSettingsSection: Component = () => {
|
|||||||
<Show when={!loading()} fallback={<div class="remote-card">{t("remoteAccess.addresses.loading")}</div>}>
|
<Show when={!loading()} fallback={<div class="remote-card">{t("remoteAccess.addresses.loading")}</div>}>
|
||||||
<Show when={!error()} fallback={<div class="remote-error">{error()}</div>}>
|
<Show when={!error()} fallback={<div class="remote-error">{error()}</div>}>
|
||||||
<Show
|
<Show
|
||||||
when={displayAddresses().length > 0 || meta()?.localUrl}
|
when={Boolean(displayAddresses().recommended) || meta()?.localUrl}
|
||||||
fallback={<div class="remote-card">{t("remoteAccess.addresses.none")}</div>}
|
fallback={<div class="remote-card">{t("remoteAccess.addresses.none")}</div>}
|
||||||
>
|
>
|
||||||
<div class="remote-address-list">
|
<div class="remote-address-list">
|
||||||
@@ -342,8 +349,9 @@ export const RemoteAccessSettingsSection: Component = () => {
|
|||||||
}}
|
}}
|
||||||
</Show>
|
</Show>
|
||||||
|
|
||||||
<For each={displayAddresses()}>
|
<Show when={displayAddresses().recommended}>
|
||||||
{(address) => {
|
{(addressAccessor) => {
|
||||||
|
const address = addressAccessor()
|
||||||
const url = address.remoteUrl
|
const url = address.remoteUrl
|
||||||
const expandedState = () => expandedUrl() === url
|
const expandedState = () => expandedUrl() === url
|
||||||
const qr = () => qrCodes()[url]
|
const qr = () => qrCodes()[url]
|
||||||
@@ -383,7 +391,11 @@ export const RemoteAccessSettingsSection: Component = () => {
|
|||||||
<div class="remote-qr">
|
<div class="remote-qr">
|
||||||
<Show when={qr()} fallback={<Loader2 class="remote-icon remote-spin" aria-hidden="true" />}>
|
<Show when={qr()} fallback={<Loader2 class="remote-icon remote-spin" aria-hidden="true" />}>
|
||||||
{(dataUrl) => (
|
{(dataUrl) => (
|
||||||
<img src={dataUrl()} alt={t("remoteAccess.address.qrAlt", { url })} class="remote-qr-img" />
|
<img
|
||||||
|
src={dataUrl()}
|
||||||
|
alt={t("remoteAccess.address.qrAlt", { url })}
|
||||||
|
class="remote-qr-img"
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
</Show>
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
@@ -391,7 +403,80 @@ export const RemoteAccessSettingsSection: Component = () => {
|
|||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}}
|
}}
|
||||||
</For>
|
</Show>
|
||||||
|
|
||||||
|
<Show when={displayAddresses().hidden.length > 0}>
|
||||||
|
<div class="remote-address-disclosure" data-expanded={showAllAddresses()}>
|
||||||
|
<button
|
||||||
|
class="remote-address-disclosure-trigger"
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShowAllAddresses(!showAllAddresses())}
|
||||||
|
aria-expanded={showAllAddresses()}
|
||||||
|
>
|
||||||
|
<span class="remote-address-disclosure-label">
|
||||||
|
{showAllAddresses()
|
||||||
|
? t("remoteAccess.addresses.actions.hideOther")
|
||||||
|
: t("remoteAccess.addresses.actions.showOther", { count: String(displayAddresses().hidden.length) })}
|
||||||
|
</span>
|
||||||
|
<ChevronDown class={`remote-address-disclosure-chevron ${showAllAddresses() ? "is-expanded" : ""}`} />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<Show when={showAllAddresses()}>
|
||||||
|
<div class="remote-address-disclosure-content">
|
||||||
|
<For each={displayAddresses().hidden}>
|
||||||
|
{(address) => {
|
||||||
|
const url = address.remoteUrl
|
||||||
|
const expandedState = () => expandedUrl() === url
|
||||||
|
const qr = () => qrCodes()[url]
|
||||||
|
const scopeLabel = () =>
|
||||||
|
address.scope === "external"
|
||||||
|
? t("remoteAccess.address.scope.network")
|
||||||
|
: address.scope === "loopback"
|
||||||
|
? t("remoteAccess.address.scope.loopback")
|
||||||
|
: t("remoteAccess.address.scope.internal")
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div class="remote-address">
|
||||||
|
<div class="remote-address-main">
|
||||||
|
<div>
|
||||||
|
<p class="remote-address-url">{url}</p>
|
||||||
|
<p class="remote-address-meta">
|
||||||
|
{address.family.toUpperCase()} - {scopeLabel()} - {address.ip}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="remote-actions">
|
||||||
|
<button class="remote-pill" type="button" onClick={() => handleOpenUrl(url)}>
|
||||||
|
<ExternalLink class="remote-icon" />
|
||||||
|
{t("remoteAccess.address.open")}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="remote-pill"
|
||||||
|
type="button"
|
||||||
|
onClick={() => void toggleExpanded(url)}
|
||||||
|
aria-expanded={expandedState()}
|
||||||
|
>
|
||||||
|
<Link2 class="remote-icon" />
|
||||||
|
{expandedState() ? t("remoteAccess.address.hideQr") : t("remoteAccess.address.showQr")}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Show when={expandedState()}>
|
||||||
|
<div class="remote-qr">
|
||||||
|
<Show when={qr()} fallback={<Loader2 class="remote-icon remote-spin" aria-hidden="true" />}>
|
||||||
|
{(dataUrl) => (
|
||||||
|
<img src={dataUrl()} alt={t("remoteAccess.address.qrAlt", { url })} class="remote-qr-img" />
|
||||||
|
)}
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
</For>
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
</Show>
|
</Show>
|
||||||
</Show>
|
</Show>
|
||||||
|
|||||||
@@ -0,0 +1,201 @@
|
|||||||
|
import { createMemo, createSignal, For, Show, onMount, type Component } from "solid-js"
|
||||||
|
import { Globe, Loader2, Plus, Trash2 } from "lucide-solid"
|
||||||
|
import { useI18n } from "../../lib/i18n"
|
||||||
|
import { serverApi } from "../../lib/api-client"
|
||||||
|
import { ensureSidecarsLoaded, sidecars, sidecarsLoading } from "../../stores/sidecars"
|
||||||
|
|
||||||
|
function deriveSidecarId(value: string): string {
|
||||||
|
return value
|
||||||
|
.trim()
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/[^a-z0-9]+/g, "-")
|
||||||
|
.replace(/-{2,}/g, "-")
|
||||||
|
.replace(/^-|-$/g, "")
|
||||||
|
}
|
||||||
|
|
||||||
|
export const SideCarsSettingsSection: Component = () => {
|
||||||
|
const { t } = useI18n()
|
||||||
|
const [name, setName] = createSignal("")
|
||||||
|
const [port, setPort] = createSignal("3000")
|
||||||
|
const [insecure, setInsecure] = createSignal(false)
|
||||||
|
const [prefixMode, setPrefixMode] = createSignal<"strip" | "preserve">("strip")
|
||||||
|
const [busyId, setBusyId] = createSignal<string | null>(null)
|
||||||
|
const [creating, setCreating] = createSignal(false)
|
||||||
|
const [formError, setFormError] = createSignal<string | null>(null)
|
||||||
|
const [actionError, setActionError] = createSignal<string | null>(null)
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
void ensureSidecarsLoaded()
|
||||||
|
})
|
||||||
|
|
||||||
|
const orderedSidecars = createMemo(() => Array.from(sidecars().values()).sort((a, b) => a.name.localeCompare(b.name)))
|
||||||
|
const derivedId = createMemo(() => deriveSidecarId(name()) || "your-sidecar")
|
||||||
|
|
||||||
|
async function handleCreate() {
|
||||||
|
const trimmedName = name().trim()
|
||||||
|
const nextPort = Number(port())
|
||||||
|
if (!trimmedName || !Number.isInteger(nextPort) || nextPort <= 0 || nextPort > 65535) {
|
||||||
|
setFormError(t("sidecars.form.validation"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setCreating(true)
|
||||||
|
setFormError(null)
|
||||||
|
try {
|
||||||
|
await serverApi.createSidecar({
|
||||||
|
kind: "port",
|
||||||
|
name: trimmedName,
|
||||||
|
port: nextPort,
|
||||||
|
insecure: insecure(),
|
||||||
|
prefixMode: prefixMode(),
|
||||||
|
})
|
||||||
|
setName("")
|
||||||
|
setPort("3000")
|
||||||
|
setInsecure(false)
|
||||||
|
setPrefixMode("strip")
|
||||||
|
} catch (error) {
|
||||||
|
setFormError(error instanceof Error ? error.message : String(error))
|
||||||
|
} finally {
|
||||||
|
setCreating(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleDelete(id: string) {
|
||||||
|
setBusyId(id)
|
||||||
|
setActionError(null)
|
||||||
|
try {
|
||||||
|
await serverApi.deleteSidecar(id)
|
||||||
|
} catch (error) {
|
||||||
|
setActionError(error instanceof Error ? error.message : String(error))
|
||||||
|
} finally {
|
||||||
|
setBusyId(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div class="settings-section-stack">
|
||||||
|
<div class="settings-card">
|
||||||
|
<div class="settings-card-header">
|
||||||
|
<div class="settings-card-heading-with-icon">
|
||||||
|
<Globe class="settings-card-heading-icon" />
|
||||||
|
<div>
|
||||||
|
<h3 class="settings-card-title">{t("settings.section.sidecars.title")}</h3>
|
||||||
|
<p class="settings-card-subtitle">{t("settings.section.sidecars.subtitle")}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<span class="settings-scope-badge settings-scope-badge-server">{t("settings.scope.server")}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="settings-card-content">
|
||||||
|
<div class="settings-toggle-row settings-toggle-row-compact">
|
||||||
|
<div>
|
||||||
|
<div class="settings-toggle-title">{t("sidecars.form.name")}</div>
|
||||||
|
<div class="settings-toggle-caption">{t("sidecars.basePath")}: <code>/sidecars/{derivedId()}</code></div>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
class="selector-input w-full max-w-xs"
|
||||||
|
value={name()}
|
||||||
|
onInput={(event) => {
|
||||||
|
setFormError(null)
|
||||||
|
setName(event.currentTarget.value)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="settings-toggle-row settings-toggle-row-compact">
|
||||||
|
<div>
|
||||||
|
<div class="settings-toggle-title">{t("sidecars.form.port")}</div>
|
||||||
|
<div class="settings-toggle-caption">127.0.0.1</div>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
class="selector-input w-full max-w-xs"
|
||||||
|
value={port()}
|
||||||
|
onInput={(event) => {
|
||||||
|
setFormError(null)
|
||||||
|
setPort(event.currentTarget.value)
|
||||||
|
}}
|
||||||
|
inputMode="numeric"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="settings-toggle-row settings-toggle-row-compact">
|
||||||
|
<div>
|
||||||
|
<div class="settings-toggle-title">{t("sidecars.form.protocol")}</div>
|
||||||
|
<div class="settings-toggle-caption">{t("sidecars.form.protocol.help")}</div>
|
||||||
|
</div>
|
||||||
|
<select class="selector-input w-full max-w-xs" value={insecure() ? "http" : "https"} onChange={(event) => setInsecure(event.currentTarget.value === "http") }>
|
||||||
|
<option value="https">{t("sidecars.form.protocol.https")}</option>
|
||||||
|
<option value="http">{t("sidecars.form.protocol.http")}</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="settings-toggle-row settings-toggle-row-compact">
|
||||||
|
<div>
|
||||||
|
<div class="settings-toggle-title">{t("sidecars.form.prefixMode")}</div>
|
||||||
|
<div class="settings-toggle-caption">{t("sidecars.form.prefixMode.help")}</div>
|
||||||
|
</div>
|
||||||
|
<select class="selector-input w-full max-w-xs" value={prefixMode()} onChange={(event) => setPrefixMode(event.currentTarget.value as "strip" | "preserve") }>
|
||||||
|
<option value="strip">{t("sidecars.form.prefixMode.strip")}</option>
|
||||||
|
<option value="preserve">{t("sidecars.form.prefixMode.preserve")}</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Show when={formError()}>
|
||||||
|
<div class="text-sm text-red-500">{formError()}</div>
|
||||||
|
</Show>
|
||||||
|
|
||||||
|
<div class="flex justify-end">
|
||||||
|
<button type="button" class="selector-button selector-button-primary" disabled={creating()} onClick={() => void handleCreate()}>
|
||||||
|
<Show when={creating()} fallback={<Plus class="w-4 h-4" />}>
|
||||||
|
<Loader2 class="w-4 h-4 animate-spin" />
|
||||||
|
</Show>
|
||||||
|
<span>{t("sidecars.form.add")}</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="settings-card">
|
||||||
|
<div class="settings-card-header">
|
||||||
|
<div>
|
||||||
|
<h3 class="settings-card-title">{t("sidecars.settings.listTitle")}</h3>
|
||||||
|
<p class="settings-card-subtitle">{t("sidecars.settings.listSubtitle")}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="settings-card-content">
|
||||||
|
<Show when={actionError()}>
|
||||||
|
<div class="text-sm text-red-500">{actionError()}</div>
|
||||||
|
</Show>
|
||||||
|
|
||||||
|
<Show when={!sidecarsLoading()} fallback={<div class="settings-card-message">{t("sidecars.picker.loading")}</div>}>
|
||||||
|
<Show when={orderedSidecars().length > 0} fallback={<div class="settings-card-message">{t("sidecars.settings.empty")}</div>}>
|
||||||
|
<For each={orderedSidecars()}>
|
||||||
|
{(sidecar) => (
|
||||||
|
<div class="settings-toggle-row settings-toggle-row-compact">
|
||||||
|
<div>
|
||||||
|
<div class="settings-toggle-title">{sidecar.name}</div>
|
||||||
|
<div class="settings-toggle-caption">
|
||||||
|
{t("sidecars.kind.port")} · {sidecar.insecure ? "http" : "https"}://127.0.0.1:{sidecar.port}
|
||||||
|
</div>
|
||||||
|
<div class="settings-toggle-caption">
|
||||||
|
{t("sidecars.basePath")}: <code>/sidecars/{sidecar.id}</code> · {t(`sidecars.form.prefixMode.${sidecar.prefixMode}`)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<span class="text-xs text-secondary min-w-[4.5rem] text-right">{t(`sidecars.status.${sidecar.status}`)}</span>
|
||||||
|
<button type="button" class="selector-button selector-button-secondary" disabled={busyId() === sidecar.id} onClick={() => void handleDelete(sidecar.id)}>
|
||||||
|
<Trash2 class="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</For>
|
||||||
|
</Show>
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
82
packages/ui/src/components/sidecar-picker-dialog.tsx
Normal file
82
packages/ui/src/components/sidecar-picker-dialog.tsx
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
import { Dialog } from "@kobalte/core/dialog"
|
||||||
|
import { For, Show, createEffect, createMemo, type Component } from "solid-js"
|
||||||
|
import { Globe, Square } from "lucide-solid"
|
||||||
|
import { useI18n } from "../lib/i18n"
|
||||||
|
import { ensureSidecarsLoaded, sidecars, sidecarsLoading } from "../stores/sidecars"
|
||||||
|
|
||||||
|
interface SideCarPickerDialogProps {
|
||||||
|
open: boolean
|
||||||
|
onClose: () => void
|
||||||
|
onOpenSidecar: (sidecarId: string) => void | Promise<void>
|
||||||
|
}
|
||||||
|
|
||||||
|
export const SideCarPickerDialog: Component<SideCarPickerDialogProps> = (props) => {
|
||||||
|
const { t } = useI18n()
|
||||||
|
const orderedSidecars = createMemo(() => Array.from(sidecars().values()).sort((a, b) => a.name.localeCompare(b.name)))
|
||||||
|
|
||||||
|
createEffect(() => {
|
||||||
|
if (props.open) {
|
||||||
|
void ensureSidecarsLoaded()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={props.open} onOpenChange={(open) => !open && props.onClose()}>
|
||||||
|
<Dialog.Portal>
|
||||||
|
<Dialog.Overlay class="modal-overlay" />
|
||||||
|
<div class="fixed inset-0 z-50 flex items-center justify-center p-4">
|
||||||
|
<Dialog.Content class="modal-surface w-full max-w-2xl p-6 flex flex-col gap-4 max-h-[80vh] overflow-hidden">
|
||||||
|
<div>
|
||||||
|
<Dialog.Title class="text-xl font-semibold text-primary">{t("sidecars.picker.title")}</Dialog.Title>
|
||||||
|
<Dialog.Description class="text-sm text-secondary mt-2">
|
||||||
|
{t("sidecars.picker.subtitle")}
|
||||||
|
</Dialog.Description>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex-1 overflow-auto flex flex-col gap-3">
|
||||||
|
<Show when={!sidecarsLoading()} fallback={<div class="panel panel-empty-state">{t("sidecars.picker.loading")}</div>}>
|
||||||
|
<Show when={orderedSidecars().length > 0} fallback={<div class="panel panel-empty-state">{t("sidecars.picker.empty")}</div>}>
|
||||||
|
<For each={orderedSidecars()}>
|
||||||
|
{(sidecar) => (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="panel-list-item panel-list-item-content text-left disabled:cursor-not-allowed disabled:opacity-60"
|
||||||
|
disabled={sidecar.status !== "running"}
|
||||||
|
onClick={() => void props.onOpenSidecar(sidecar.id)}
|
||||||
|
>
|
||||||
|
<div class="flex items-center justify-between gap-4 w-full">
|
||||||
|
<div class="flex items-center gap-3 min-w-0">
|
||||||
|
<span class="panel-empty-state-icon !w-10 !h-10">
|
||||||
|
<Globe class="w-5 h-5" />
|
||||||
|
</span>
|
||||||
|
<div class="min-w-0">
|
||||||
|
<div class="text-sm font-medium text-primary truncate">{sidecar.name}</div>
|
||||||
|
<div class="text-xs text-muted">
|
||||||
|
{t("sidecars.kind.port")} - {sidecar.insecure ? "http" : "https"}://127.0.0.1:{sidecar.port}
|
||||||
|
</div>
|
||||||
|
<div class="text-xs text-muted mt-1">{t("sidecars.basePath")}: <code>/sidecars/{sidecar.id}</code></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="text-xs text-secondary flex items-center gap-2">
|
||||||
|
<Square class="w-4 h-4" />
|
||||||
|
<span>{t(`sidecars.status.${sidecar.status}`)}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</For>
|
||||||
|
</Show>
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex justify-end">
|
||||||
|
<button type="button" class="selector-button selector-button-secondary" onClick={props.onClose}>
|
||||||
|
{t("sidecars.picker.close")}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</Dialog.Content>
|
||||||
|
</div>
|
||||||
|
</Dialog.Portal>
|
||||||
|
</Dialog>
|
||||||
|
)
|
||||||
|
}
|
||||||
197
packages/ui/src/components/sidecar-view.tsx
Normal file
197
packages/ui/src/components/sidecar-view.tsx
Normal file
@@ -0,0 +1,197 @@
|
|||||||
|
import { ArrowLeft, ArrowRight, RefreshCw } from "lucide-solid"
|
||||||
|
import { createEffect, createMemo, createSignal, type Component } from "solid-js"
|
||||||
|
import type { SideCarTabRecord } from "../stores/sidecars"
|
||||||
|
import { useI18n } from "../lib/i18n"
|
||||||
|
|
||||||
|
interface SideCarViewProps {
|
||||||
|
tab: SideCarTabRecord
|
||||||
|
}
|
||||||
|
|
||||||
|
export const SideCarView: Component<SideCarViewProps> = (props) => {
|
||||||
|
const { t } = useI18n()
|
||||||
|
const [frameSrc, setFrameSrc] = createSignal(props.tab.shellUrl)
|
||||||
|
const [pathInput, setPathInput] = createSignal("/")
|
||||||
|
let iframeRef: HTMLIFrameElement | undefined
|
||||||
|
|
||||||
|
const lockedBaseLabel = createMemo(() => {
|
||||||
|
const hostLabel = props.tab.port ? `${props.tab.name}:${props.tab.port}` : props.tab.name
|
||||||
|
if (props.tab.prefixMode === "preserve") {
|
||||||
|
return `${hostLabel}${props.tab.proxyBasePath}`
|
||||||
|
}
|
||||||
|
return hostLabel
|
||||||
|
})
|
||||||
|
|
||||||
|
const getEditablePathFromUrl = (url: string): string => {
|
||||||
|
try {
|
||||||
|
const parsed = new URL(url, window.location.origin)
|
||||||
|
const basePath = props.tab.proxyBasePath
|
||||||
|
let pathname = parsed.pathname
|
||||||
|
|
||||||
|
if (basePath && pathname.startsWith(basePath)) {
|
||||||
|
pathname = pathname.slice(basePath.length) || "/"
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!pathname.startsWith("/")) {
|
||||||
|
pathname = `/${pathname}`
|
||||||
|
}
|
||||||
|
|
||||||
|
return `${pathname}${parsed.search}${parsed.hash}`
|
||||||
|
} catch {
|
||||||
|
return "/"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const buildNormalizedTargetUrl = (rawInput: string): string => {
|
||||||
|
const trimmed = rawInput.trim()
|
||||||
|
const withLeadingSlash = trimmed.startsWith("/") ? trimmed : `/${trimmed}`
|
||||||
|
const parsed = new URL(withLeadingSlash || "/", window.location.origin)
|
||||||
|
|
||||||
|
const safeSegments: string[] = []
|
||||||
|
for (const segment of parsed.pathname.split("/")) {
|
||||||
|
if (!segment || segment === ".") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if (segment === "..") {
|
||||||
|
if (safeSegments.length > 0) {
|
||||||
|
safeSegments.pop()
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
safeSegments.push(segment)
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalizedPath = `/${safeSegments.join("/")}` || "/"
|
||||||
|
const basePath = props.tab.proxyBasePath
|
||||||
|
return `${basePath}${normalizedPath}${parsed.search}${parsed.hash}`
|
||||||
|
}
|
||||||
|
|
||||||
|
const syncPathInputFromFrame = () => {
|
||||||
|
try {
|
||||||
|
const currentHref = iframeRef?.contentWindow?.location.href
|
||||||
|
if (!currentHref) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setPathInput(getEditablePathFromUrl(currentHref))
|
||||||
|
} catch {
|
||||||
|
setPathInput(getEditablePathFromUrl(frameSrc()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
createEffect(() => {
|
||||||
|
setFrameSrc(props.tab.shellUrl)
|
||||||
|
setPathInput(getEditablePathFromUrl(props.tab.shellUrl))
|
||||||
|
})
|
||||||
|
|
||||||
|
const handleBack = (event: MouseEvent) => {
|
||||||
|
event.preventDefault()
|
||||||
|
event.stopPropagation()
|
||||||
|
|
||||||
|
try {
|
||||||
|
const frameWindow = iframeRef?.contentWindow
|
||||||
|
if (!frameWindow) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (frameWindow.history.length <= 1) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
frameWindow.focus()
|
||||||
|
frameWindow.history.go(-1)
|
||||||
|
} catch {
|
||||||
|
// Ignore navigation errors from pages that do not expose history access.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleRefresh = () => {
|
||||||
|
try {
|
||||||
|
iframeRef?.contentWindow?.location.reload()
|
||||||
|
return
|
||||||
|
} catch {
|
||||||
|
// Fall back to resetting the iframe source if the frame cannot be reloaded directly.
|
||||||
|
}
|
||||||
|
|
||||||
|
setFrameSrc("about:blank")
|
||||||
|
requestAnimationFrame(() => setFrameSrc(props.tab.shellUrl))
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleGo = (event?: Event) => {
|
||||||
|
event?.preventDefault()
|
||||||
|
|
||||||
|
const nextUrl = buildNormalizedTargetUrl(pathInput())
|
||||||
|
setFrameSrc(nextUrl)
|
||||||
|
setPathInput(getEditablePathFromUrl(nextUrl))
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div class="flex h-full min-h-0 w-full flex-col bg-surface">
|
||||||
|
<div
|
||||||
|
class="flex shrink-0 items-center gap-2 px-3 py-2"
|
||||||
|
style={{ "border-bottom": "1px solid var(--border-base)" }}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="new-tab-button"
|
||||||
|
onClick={handleBack}
|
||||||
|
title={t("sidecars.back")}
|
||||||
|
aria-label={t("sidecars.back")}
|
||||||
|
>
|
||||||
|
<ArrowLeft class="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="new-tab-button"
|
||||||
|
onClick={handleRefresh}
|
||||||
|
title={t("sidecars.refresh")}
|
||||||
|
aria-label={t("sidecars.refresh")}
|
||||||
|
>
|
||||||
|
<RefreshCw class="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
<div
|
||||||
|
class="shrink-0 rounded-md px-3 py-1.5 text-sm"
|
||||||
|
style={{
|
||||||
|
background: "var(--surface-secondary)",
|
||||||
|
color: "var(--text-secondary)",
|
||||||
|
border: "1px solid var(--border-base)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{lockedBaseLabel()}
|
||||||
|
</div>
|
||||||
|
<form class="flex min-w-0 flex-1 items-center gap-2" onSubmit={(event) => handleGo(event)}>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
class="min-w-0 flex-1 rounded-md px-3 py-1.5 text-sm outline-none"
|
||||||
|
style={{
|
||||||
|
background: "var(--surface-secondary)",
|
||||||
|
color: "var(--text-primary)",
|
||||||
|
border: "1px solid var(--border-base)",
|
||||||
|
}}
|
||||||
|
value={pathInput()}
|
||||||
|
onInput={(event) => setPathInput(event.currentTarget.value)}
|
||||||
|
spellcheck={false}
|
||||||
|
autocomplete="off"
|
||||||
|
autocorrect="off"
|
||||||
|
autocapitalize="off"
|
||||||
|
aria-label={t("sidecars.path")}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
class="new-tab-button"
|
||||||
|
title={t("sidecars.go")}
|
||||||
|
aria-label={t("sidecars.go")}
|
||||||
|
>
|
||||||
|
<ArrowRight class="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
<iframe
|
||||||
|
ref={iframeRef}
|
||||||
|
src={frameSrc()}
|
||||||
|
title={props.tab.name}
|
||||||
|
class="min-h-0 flex-1 w-full border-0 bg-surface"
|
||||||
|
referrerPolicy="same-origin"
|
||||||
|
onLoad={syncPathInputFromFrame}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,10 +1,13 @@
|
|||||||
import { Suspense, lazy, onMount, type Accessor, type JSXElement } from "solid-js"
|
import { Suspense, createEffect, createMemo, createSignal, lazy, onMount, type Accessor, type JSXElement } from "solid-js"
|
||||||
import type { ToolState } from "@opencode-ai/sdk/v2"
|
import type { ToolState } from "@opencode-ai/sdk/v2"
|
||||||
|
import useMediaQuery from "@suid/material/useMediaQuery"
|
||||||
|
import { AlignJustify, Copy, Split, WrapText } from "lucide-solid"
|
||||||
import type { RenderCache } from "../../types/message"
|
import type { RenderCache } from "../../types/message"
|
||||||
import type { DiffViewMode } from "../../stores/preferences"
|
import type { DiffViewMode } from "../../stores/preferences"
|
||||||
import type { DiffPayload, DiffRenderOptions, ToolScrollHelpers } from "./types"
|
import type { DiffPayload, DiffRenderOptions, ToolScrollHelpers } from "./types"
|
||||||
import { getRelativePath } from "./utils"
|
import { getRelativePath } from "./utils"
|
||||||
import { getCacheEntry } from "../../lib/global-cache"
|
import { getCacheEntry } from "../../lib/global-cache"
|
||||||
|
import { copyToClipboard } from "../../lib/clipboard"
|
||||||
|
|
||||||
const LazyToolCallDiffViewer = lazy(() =>
|
const LazyToolCallDiffViewer = lazy(() =>
|
||||||
import("../diff-viewer").then((module) => ({ default: module.ToolCallDiffViewer })),
|
import("../diff-viewer").then((module) => ({ default: module.ToolCallDiffViewer })),
|
||||||
@@ -43,6 +46,16 @@ export function createDiffContentRenderer(params: {
|
|||||||
handleScrollRendered: () => void
|
handleScrollRendered: () => void
|
||||||
onContentRendered?: () => void
|
onContentRendered?: () => void
|
||||||
}) {
|
}) {
|
||||||
|
const compactDiffQuery = useMediaQuery("(max-width: 640px)")
|
||||||
|
const [mobileModeOverride, setMobileModeOverride] = createSignal<DiffViewMode | undefined>(undefined)
|
||||||
|
const [wordWrapEnabled, setWordWrapEnabled] = createSignal(true)
|
||||||
|
|
||||||
|
createEffect(() => {
|
||||||
|
if (!compactDiffQuery()) {
|
||||||
|
setMobileModeOverride(undefined)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
const registerTracked = (element: HTMLDivElement | null) => {
|
const registerTracked = (element: HTMLDivElement | null) => {
|
||||||
params.scrollHelpers.registerContainer(element)
|
params.scrollHelpers.registerContainer(element)
|
||||||
}
|
}
|
||||||
@@ -58,7 +71,12 @@ export function createDiffContentRenderer(params: {
|
|||||||
: params.t("toolCall.diff.label"))
|
: params.t("toolCall.diff.label"))
|
||||||
const selectedVariant = options?.variant === "permission-diff" ? "permission-diff" : "diff"
|
const selectedVariant = options?.variant === "permission-diff" ? "permission-diff" : "diff"
|
||||||
const cacheHandle = selectedVariant === "permission-diff" ? params.permissionDiffCache : params.diffCache
|
const cacheHandle = selectedVariant === "permission-diff" ? params.permissionDiffCache : params.diffCache
|
||||||
const diffMode = () => (params.preferences().diffViewMode || "split") as DiffViewMode
|
const preferredMode = () => (params.preferences().diffViewMode || "split") as DiffViewMode
|
||||||
|
const effectiveMode = () => {
|
||||||
|
if (!compactDiffQuery()) return preferredMode()
|
||||||
|
return mobileModeOverride() || "unified"
|
||||||
|
}
|
||||||
|
const shouldWrap = () => wordWrapEnabled()
|
||||||
const themeKey = params.isDark() ? "dark" : "light"
|
const themeKey = params.isDark() ? "dark" : "light"
|
||||||
const state = params.toolState()
|
const state = params.toolState()
|
||||||
const disableScrollTracking = Boolean(
|
const disableScrollTracking = Boolean(
|
||||||
@@ -76,17 +94,40 @@ export function createDiffContentRenderer(params: {
|
|||||||
}
|
}
|
||||||
})()
|
})()
|
||||||
|
|
||||||
let cachedHtml: string | undefined
|
const currentMode = createMemo(() => effectiveMode())
|
||||||
const cached = getCacheEntry<RenderCache>(cacheEntryParams)
|
const currentWrap = createMemo(() => shouldWrap())
|
||||||
const currentMode = diffMode()
|
const cachedHtml = createMemo(() => {
|
||||||
if (cached && cached.text === payload.diffText && cached.theme === themeKey && cached.mode === currentMode) {
|
const cached = getCacheEntry<RenderCache>(cacheEntryParams)
|
||||||
cachedHtml = cached.html
|
if (
|
||||||
}
|
cached
|
||||||
|
&& cached.text === payload.diffText
|
||||||
|
&& cached.theme === themeKey
|
||||||
|
&& cached.mode === currentMode()
|
||||||
|
&& cached.wrap === currentWrap()
|
||||||
|
) {
|
||||||
|
return cached.html
|
||||||
|
}
|
||||||
|
return undefined
|
||||||
|
})
|
||||||
|
|
||||||
const handleModeChange = (mode: DiffViewMode) => {
|
const handleModeChange = (mode: DiffViewMode) => {
|
||||||
|
if (compactDiffQuery()) {
|
||||||
|
setMobileModeOverride(mode)
|
||||||
|
}
|
||||||
params.setDiffViewMode(mode)
|
params.setDiffViewMode(mode)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const nextViewMode = (): DiffViewMode => (currentMode() === "split" ? "unified" : "split")
|
||||||
|
const viewModeTitle = () =>
|
||||||
|
nextViewMode() === "split"
|
||||||
|
? params.t("toolCall.diff.switchToSplit")
|
||||||
|
: params.t("toolCall.diff.switchToUnified")
|
||||||
|
const wordWrapTitle = () =>
|
||||||
|
wordWrapEnabled()
|
||||||
|
? params.t("toolCall.diff.disableWordWrap")
|
||||||
|
: params.t("toolCall.diff.enableWordWrap")
|
||||||
|
const copyPatchTitle = () => params.t("toolCall.diff.copyPatch")
|
||||||
|
|
||||||
const handleDiffRendered = () => {
|
const handleDiffRendered = () => {
|
||||||
if (!disableScrollTracking) {
|
if (!disableScrollTracking) {
|
||||||
params.handleScrollRendered()
|
params.handleScrollRendered()
|
||||||
@@ -95,41 +136,54 @@ export function createDiffContentRenderer(params: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
class="message-text tool-call-markdown tool-call-markdown-large tool-call-diff-shell"
|
class="message-text tool-call-markdown tool-call-markdown-large tool-call-diff-shell"
|
||||||
ref={registerRef}
|
data-diff-mode={currentMode()}
|
||||||
onScroll={disableScrollTracking ? undefined : params.scrollHelpers.handleScroll}
|
ref={registerRef}
|
||||||
>
|
onScroll={disableScrollTracking ? undefined : params.scrollHelpers.handleScroll}
|
||||||
|
>
|
||||||
<div class="tool-call-diff-toolbar" role="group" aria-label={params.t("toolCall.diff.viewMode.ariaLabel")}>
|
<div class="tool-call-diff-toolbar" role="group" aria-label={params.t("toolCall.diff.viewMode.ariaLabel")}>
|
||||||
<span class="tool-call-diff-toolbar-label">{toolbarLabel}</span>
|
<span class="tool-call-diff-toolbar-label">{toolbarLabel}</span>
|
||||||
<div class="tool-call-diff-toggle">
|
<div class="file-viewer-toolbar">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class={`tool-call-diff-mode-button${diffMode() === "split" ? " active" : ""}`}
|
class="file-viewer-toolbar-icon-button"
|
||||||
aria-pressed={diffMode() === "split"}
|
onClick={() => void copyToClipboard(payload.diffText)}
|
||||||
onClick={() => handleModeChange("split")}
|
aria-label={copyPatchTitle()}
|
||||||
|
title={copyPatchTitle()}
|
||||||
>
|
>
|
||||||
{params.t("toolCall.diff.viewMode.split")}
|
<Copy class="h-4 w-4" aria-hidden="true" />
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class={`tool-call-diff-mode-button${diffMode() === "unified" ? " active" : ""}`}
|
class="file-viewer-toolbar-icon-button"
|
||||||
aria-pressed={diffMode() === "unified"}
|
onClick={() => handleModeChange(nextViewMode())}
|
||||||
onClick={() => handleModeChange("unified")}
|
aria-label={viewModeTitle()}
|
||||||
|
title={viewModeTitle()}
|
||||||
>
|
>
|
||||||
{params.t("toolCall.diff.viewMode.unified")}
|
{nextViewMode() === "split" ? <Split class="h-4 w-4" aria-hidden="true" /> : <AlignJustify class="h-4 w-4" aria-hidden="true" />}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class={`file-viewer-toolbar-icon-button${wordWrapEnabled() ? " active" : ""}`}
|
||||||
|
onClick={() => setWordWrapEnabled((enabled) => !enabled)}
|
||||||
|
aria-label={wordWrapTitle()}
|
||||||
|
title={wordWrapTitle()}
|
||||||
|
>
|
||||||
|
<WrapText class="h-4 w-4" aria-hidden="true" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{cachedHtml ? (
|
{cachedHtml() ? (
|
||||||
<CachedDiffMarkup html={cachedHtml} onRendered={handleDiffRendered} />
|
<CachedDiffMarkup html={cachedHtml()!} onRendered={handleDiffRendered} />
|
||||||
) : (
|
) : (
|
||||||
<Suspense fallback={<pre class="tool-call-diff-fallback">{payload.diffText}</pre>}>
|
<Suspense fallback={<pre class="tool-call-diff-fallback">{payload.diffText}</pre>}>
|
||||||
<LazyToolCallDiffViewer
|
<LazyToolCallDiffViewer
|
||||||
diffText={payload.diffText}
|
diffText={payload.diffText}
|
||||||
filePath={payload.filePath}
|
filePath={payload.filePath}
|
||||||
theme={themeKey}
|
theme={themeKey}
|
||||||
mode={diffMode()}
|
mode={currentMode()}
|
||||||
|
wrap={currentWrap()}
|
||||||
cacheEntryParams={cacheEntryParams as any}
|
cacheEntryParams={cacheEntryParams as any}
|
||||||
onRendered={handleDiffRendered}
|
onRendered={handleDiffRendered}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -10,7 +10,10 @@ import type {
|
|||||||
SpeechCapabilitiesResponse,
|
SpeechCapabilitiesResponse,
|
||||||
SpeechSynthesisResponse,
|
SpeechSynthesisResponse,
|
||||||
SpeechTranscriptionResponse,
|
SpeechTranscriptionResponse,
|
||||||
|
SideCar,
|
||||||
ServerMeta,
|
ServerMeta,
|
||||||
|
RemoteServerProbeRequest,
|
||||||
|
RemoteServerProbeResponse,
|
||||||
VoiceModeStateResponse,
|
VoiceModeStateResponse,
|
||||||
WorkspaceCreateRequest,
|
WorkspaceCreateRequest,
|
||||||
WorkspaceDescriptor,
|
WorkspaceDescriptor,
|
||||||
@@ -24,6 +27,7 @@ import type {
|
|||||||
WorktreeMap,
|
WorktreeMap,
|
||||||
WorktreeCreateRequest,
|
WorktreeCreateRequest,
|
||||||
} from "../../../server/src/api-types"
|
} from "../../../server/src/api-types"
|
||||||
|
import { getClientIdentity } from "./client-identity"
|
||||||
import { getLogger } from "./logger"
|
import { getLogger } from "./logger"
|
||||||
|
|
||||||
const RUNTIME_BASE = typeof window !== "undefined" ? window.location?.origin : undefined
|
const RUNTIME_BASE = typeof window !== "undefined" ? window.location?.origin : undefined
|
||||||
@@ -190,9 +194,42 @@ export const serverApi = {
|
|||||||
body: JSON.stringify(payload),
|
body: JSON.stringify(payload),
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
|
fetchSidecars(): Promise<{ sidecars: SideCar[] }> {
|
||||||
|
return request<{ sidecars: SideCar[] }>("/api/sidecars")
|
||||||
|
},
|
||||||
|
createSidecar(payload: {
|
||||||
|
kind: "port"
|
||||||
|
name: string
|
||||||
|
port: number
|
||||||
|
insecure: boolean
|
||||||
|
prefixMode: "strip" | "preserve"
|
||||||
|
}): Promise<SideCar> {
|
||||||
|
return request<SideCar>("/api/sidecars", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
})
|
||||||
|
},
|
||||||
|
updateSidecar(
|
||||||
|
id: string,
|
||||||
|
payload: Partial<{ name: string; port: number; insecure: boolean; prefixMode: "strip" | "preserve" }>,
|
||||||
|
): Promise<SideCar> {
|
||||||
|
return request<SideCar>(`/api/sidecars/${encodeURIComponent(id)}`, {
|
||||||
|
method: "PUT",
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
})
|
||||||
|
},
|
||||||
|
deleteSidecar(id: string): Promise<void> {
|
||||||
|
return request(`/api/sidecars/${encodeURIComponent(id)}`, { method: "DELETE" })
|
||||||
|
},
|
||||||
fetchServerMeta(): Promise<ServerMeta> {
|
fetchServerMeta(): Promise<ServerMeta> {
|
||||||
return request<ServerMeta>("/api/meta")
|
return request<ServerMeta>("/api/meta")
|
||||||
},
|
},
|
||||||
|
probeRemoteServer(payload: RemoteServerProbeRequest): Promise<RemoteServerProbeResponse> {
|
||||||
|
return request<RemoteServerProbeResponse>("/api/remote-servers/probe", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
})
|
||||||
|
},
|
||||||
fetchAuthStatus(): Promise<{ authenticated: boolean; username?: string; passwordUserProvided?: boolean }> {
|
fetchAuthStatus(): Promise<{ authenticated: boolean; username?: string; passwordUserProvided?: boolean }> {
|
||||||
return request<{ authenticated: boolean; username?: string; passwordUserProvided?: boolean }>("/api/auth/status")
|
return request<{ authenticated: boolean; username?: string; passwordUserProvided?: boolean }>("/api/auth/status")
|
||||||
},
|
},
|
||||||
@@ -350,9 +387,16 @@ export const serverApi = {
|
|||||||
)
|
)
|
||||||
},
|
},
|
||||||
updateVoiceMode(instanceId: string, enabled: boolean): Promise<VoiceModeStateResponse> {
|
updateVoiceMode(instanceId: string, enabled: boolean): Promise<VoiceModeStateResponse> {
|
||||||
|
const identity = getClientIdentity()
|
||||||
return request<VoiceModeStateResponse>(`/workspaces/${encodeURIComponent(instanceId)}/plugin/voice-mode`, {
|
return request<VoiceModeStateResponse>(`/workspaces/${encodeURIComponent(instanceId)}/plugin/voice-mode`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
body: JSON.stringify({ enabled }),
|
body: JSON.stringify({ ...identity, enabled }),
|
||||||
|
})
|
||||||
|
},
|
||||||
|
sendClientConnectionPong(payload: { clientId: string; connectionId: string; pingTs?: number }): Promise<void> {
|
||||||
|
return request<void>("/api/client-connections/pong", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify(payload),
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
fetchBackgroundProcessOutput(
|
fetchBackgroundProcessOutput(
|
||||||
@@ -379,9 +423,15 @@ export const serverApi = {
|
|||||||
`/workspaces/${encodeURIComponent(instanceId)}/plugin/background-processes/${encodeURIComponent(processId)}/output${suffix}`,
|
`/workspaces/${encodeURIComponent(instanceId)}/plugin/background-processes/${encodeURIComponent(processId)}/output${suffix}`,
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
connectEvents(onEvent: (event: WorkspaceEventPayload) => void, onError?: () => void) {
|
connectEvents(
|
||||||
sseLogger.info(`Connecting to ${EVENTS_URL}`)
|
onEvent: (event: WorkspaceEventPayload) => void,
|
||||||
const source = new EventSource(EVENTS_URL, { withCredentials: true } as any)
|
onError?: () => void,
|
||||||
|
onPing?: (payload: { ts?: number }) => void,
|
||||||
|
) {
|
||||||
|
const identity = getClientIdentity()
|
||||||
|
const url = buildClientEventsUrl(identity)
|
||||||
|
sseLogger.info(`Connecting to ${url}`)
|
||||||
|
const source = new EventSource(url, { withCredentials: true } as any)
|
||||||
source.onmessage = (event) => {
|
source.onmessage = (event) => {
|
||||||
try {
|
try {
|
||||||
const payload = JSON.parse(event.data) as WorkspaceEventPayload
|
const payload = JSON.parse(event.data) as WorkspaceEventPayload
|
||||||
@@ -394,8 +444,26 @@ export const serverApi = {
|
|||||||
sseLogger.warn("EventSource error, closing stream")
|
sseLogger.warn("EventSource error, closing stream")
|
||||||
onError?.()
|
onError?.()
|
||||||
}
|
}
|
||||||
|
source.addEventListener("codenomad.client.ping", (event: MessageEvent) => {
|
||||||
|
try {
|
||||||
|
const payload = event.data ? (JSON.parse(event.data) as { ts?: number }) : {}
|
||||||
|
onPing?.(payload)
|
||||||
|
} catch (error) {
|
||||||
|
sseLogger.error("Failed to parse ping event", error)
|
||||||
|
}
|
||||||
|
})
|
||||||
return source
|
return source
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
export type { WorkspaceDescriptor, WorkspaceLogEntry, WorkspaceEventPayload, WorkspaceEventType }
|
function buildClientEventsUrl(identity: { clientId: string; connectionId: string }): string {
|
||||||
|
const url = new URL(EVENTS_URL, typeof window !== "undefined" ? window.location.origin : "http://localhost")
|
||||||
|
url.searchParams.set("clientId", identity.clientId)
|
||||||
|
url.searchParams.set("connectionId", identity.connectionId)
|
||||||
|
if (EVENTS_URL.startsWith("http://") || EVENTS_URL.startsWith("https://")) {
|
||||||
|
return url.toString()
|
||||||
|
}
|
||||||
|
return `${url.pathname}${url.search}`
|
||||||
|
}
|
||||||
|
|
||||||
|
export type { WorkspaceDescriptor, WorkspaceLogEntry, WorkspaceEventPayload, WorkspaceEventType, SideCar }
|
||||||
|
|||||||
58
packages/ui/src/lib/client-identity.ts
Normal file
58
packages/ui/src/lib/client-identity.ts
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
const CLIENT_ID_STORAGE_KEY = "codenomad.client-id"
|
||||||
|
const CONNECTION_ID_STORAGE_KEY = "codenomad.connection-id"
|
||||||
|
|
||||||
|
let cachedClientId: string | null = null
|
||||||
|
let cachedConnectionId: string | null = null
|
||||||
|
|
||||||
|
export function getClientIdentity(): { clientId: string; connectionId: string } {
|
||||||
|
return {
|
||||||
|
clientId: getOrCreateClientId(),
|
||||||
|
connectionId: getOrCreateConnectionId(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getOrCreateClientId(): string {
|
||||||
|
if (cachedClientId) return cachedClientId
|
||||||
|
cachedClientId = getOrCreateStoredValue(CLIENT_ID_STORAGE_KEY, window.localStorage)
|
||||||
|
return cachedClientId
|
||||||
|
}
|
||||||
|
|
||||||
|
function getOrCreateConnectionId(): string {
|
||||||
|
if (cachedConnectionId) return cachedConnectionId
|
||||||
|
cachedConnectionId = getOrCreateStoredValue(CONNECTION_ID_STORAGE_KEY, window.sessionStorage)
|
||||||
|
return cachedConnectionId
|
||||||
|
}
|
||||||
|
|
||||||
|
function getOrCreateStoredValue(key: string, storage: Storage): string {
|
||||||
|
if (typeof window === "undefined") {
|
||||||
|
return generateUUID()
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const existing = storage.getItem(key)
|
||||||
|
if (existing && existing.trim()) {
|
||||||
|
return existing.trim()
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
return generateUUID()
|
||||||
|
}
|
||||||
|
|
||||||
|
const next = generateUUID()
|
||||||
|
try {
|
||||||
|
storage.setItem(key, next)
|
||||||
|
} catch {
|
||||||
|
// Ignore storage failures and fall back to the in-memory value.
|
||||||
|
}
|
||||||
|
return next
|
||||||
|
}
|
||||||
|
|
||||||
|
function generateUUID(): string {
|
||||||
|
if (typeof crypto !== "undefined" && crypto.randomUUID) {
|
||||||
|
return crypto.randomUUID()
|
||||||
|
}
|
||||||
|
return "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g, (char) => {
|
||||||
|
const random = (Math.random() * 16) | 0
|
||||||
|
const value = char === "x" ? random : (random & 0x3) | 0x8
|
||||||
|
return value.toString(16)
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -19,9 +19,6 @@ export function formatCompactCount(value: number): string {
|
|||||||
return `${(value / 1_000_000).toFixed(1)}M`
|
return `${(value / 1_000_000).toFixed(1)}M`
|
||||||
}
|
}
|
||||||
if (value >= 10_000) {
|
if (value >= 10_000) {
|
||||||
return `${Math.round(value / 1_000)}K`
|
|
||||||
}
|
|
||||||
if (value >= 1_000) {
|
|
||||||
const label = `${(value / 1_000).toFixed(1)}K`
|
const label = `${(value / 1_000).toFixed(1)}K`
|
||||||
return label.replace(/\.0K$/, "K")
|
return label.replace(/\.0K$/, "K")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ const log = getLogger("actions")
|
|||||||
interface UseAppLifecycleOptions {
|
interface UseAppLifecycleOptions {
|
||||||
setEscapeInDebounce: (value: boolean) => void
|
setEscapeInDebounce: (value: boolean) => void
|
||||||
handleNewInstanceRequest: () => void
|
handleNewInstanceRequest: () => void
|
||||||
|
handleCloseActiveTab: () => Promise<void>
|
||||||
handleCloseInstance: (instanceId: string) => Promise<void>
|
handleCloseInstance: (instanceId: string) => Promise<void>
|
||||||
handleNewSession: (instanceId: string) => Promise<void>
|
handleNewSession: (instanceId: string) => Promise<void>
|
||||||
handleCloseSession: (instanceId: string, sessionId: string) => Promise<void>
|
handleCloseSession: (instanceId: string, sessionId: string) => Promise<void>
|
||||||
@@ -31,7 +32,7 @@ export function useAppLifecycle(options: UseAppLifecycleOptions) {
|
|||||||
|
|
||||||
setupTabKeyboardShortcuts(
|
setupTabKeyboardShortcuts(
|
||||||
options.handleNewInstanceRequest,
|
options.handleNewInstanceRequest,
|
||||||
options.handleCloseInstance,
|
options.handleCloseActiveTab,
|
||||||
options.handleNewSession,
|
options.handleNewSession,
|
||||||
options.handleCloseSession,
|
options.handleCloseSession,
|
||||||
() => {
|
() => {
|
||||||
|
|||||||
@@ -2,7 +2,8 @@ import { createSignal, onMount } from "solid-js"
|
|||||||
import type { Accessor } from "solid-js"
|
import type { Accessor } from "solid-js"
|
||||||
import type { Preferences, ExpansionPreference, ToolInputsVisibilityPreference } from "../../stores/preferences"
|
import type { Preferences, ExpansionPreference, ToolInputsVisibilityPreference } from "../../stores/preferences"
|
||||||
import { createCommandRegistry, type Command } from "../commands"
|
import { createCommandRegistry, type Command } from "../commands"
|
||||||
import { instances, activeInstanceId, setActiveInstanceId } from "../../stores/instances"
|
import { activeInstanceId } from "../../stores/instances"
|
||||||
|
import { selectNextAppTab, selectPreviousAppTab } from "../../stores/app-tabs"
|
||||||
import type { ClientPart, MessageInfo } from "../../types/message"
|
import type { ClientPart, MessageInfo } from "../../types/message"
|
||||||
import { getSessions, getVisibleSessionIds, setActiveSession, setActiveSessionFromList } from "../../stores/sessions"
|
import { getSessions, getVisibleSessionIds, setActiveSession, setActiveSessionFromList } from "../../stores/sessions"
|
||||||
import { showAlertDialog } from "../../stores/alerts"
|
import { showAlertDialog } from "../../stores/alerts"
|
||||||
@@ -41,6 +42,7 @@ export interface UseCommandsOptions {
|
|||||||
setThinkingBlocksExpansion: (mode: ExpansionPreference) => void
|
setThinkingBlocksExpansion: (mode: ExpansionPreference) => void
|
||||||
setToolInputsVisibility: (mode: ToolInputsVisibilityPreference) => void
|
setToolInputsVisibility: (mode: ToolInputsVisibilityPreference) => void
|
||||||
handleNewInstanceRequest: () => void
|
handleNewInstanceRequest: () => void
|
||||||
|
handleCloseActiveTab: () => Promise<void>
|
||||||
handleCloseInstance: (instanceId: string) => Promise<void>
|
handleCloseInstance: (instanceId: string) => Promise<void>
|
||||||
handleNewSession: (instanceId: string) => Promise<void>
|
handleNewSession: (instanceId: string) => Promise<void>
|
||||||
handleCloseSession: (instanceId: string, sessionId: string) => Promise<void>
|
handleCloseSession: (instanceId: string, sessionId: string) => Promise<void>
|
||||||
@@ -90,9 +92,7 @@ export function useCommands(options: UseCommandsOptions) {
|
|||||||
keywords: () => splitKeywords("commands.closeInstance.keywords"),
|
keywords: () => splitKeywords("commands.closeInstance.keywords"),
|
||||||
shortcut: { key: "W", meta: true },
|
shortcut: { key: "W", meta: true },
|
||||||
action: async () => {
|
action: async () => {
|
||||||
const instance = activeInstance()
|
await options.handleCloseActiveTab()
|
||||||
if (!instance) return
|
|
||||||
await options.handleCloseInstance(instance.id)
|
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -103,13 +103,7 @@ export function useCommands(options: UseCommandsOptions) {
|
|||||||
category: "Instance",
|
category: "Instance",
|
||||||
keywords: () => splitKeywords("commands.nextInstance.keywords"),
|
keywords: () => splitKeywords("commands.nextInstance.keywords"),
|
||||||
shortcut: { key: "]", meta: true },
|
shortcut: { key: "]", meta: true },
|
||||||
action: () => {
|
action: () => selectNextAppTab(),
|
||||||
const ids = Array.from(instances().keys())
|
|
||||||
if (ids.length <= 1) return
|
|
||||||
const current = ids.indexOf(activeInstanceId() || "")
|
|
||||||
const next = (current + 1) % ids.length
|
|
||||||
if (ids[next]) setActiveInstanceId(ids[next])
|
|
||||||
},
|
|
||||||
})
|
})
|
||||||
|
|
||||||
commandRegistry.register({
|
commandRegistry.register({
|
||||||
@@ -119,13 +113,7 @@ export function useCommands(options: UseCommandsOptions) {
|
|||||||
category: "Instance",
|
category: "Instance",
|
||||||
keywords: () => splitKeywords("commands.previousInstance.keywords"),
|
keywords: () => splitKeywords("commands.previousInstance.keywords"),
|
||||||
shortcut: { key: "[", meta: true },
|
shortcut: { key: "[", meta: true },
|
||||||
action: () => {
|
action: () => selectPreviousAppTab(),
|
||||||
const ids = Array.from(instances().keys())
|
|
||||||
if (ids.length <= 1) return
|
|
||||||
const current = ids.indexOf(activeInstanceId() || "")
|
|
||||||
const prev = current <= 0 ? ids.length - 1 : current - 1
|
|
||||||
if (ids[prev]) setActiveInstanceId(ids[prev])
|
|
||||||
},
|
|
||||||
})
|
})
|
||||||
|
|
||||||
commandRegistry.register({
|
commandRegistry.register({
|
||||||
|
|||||||
@@ -15,17 +15,17 @@ export const commandMessages = {
|
|||||||
"commands.newInstance.description": "Open folder picker to create new instance",
|
"commands.newInstance.description": "Open folder picker to create new instance",
|
||||||
"commands.newInstance.keywords": "folder, project, workspace",
|
"commands.newInstance.keywords": "folder, project, workspace",
|
||||||
|
|
||||||
"commands.closeInstance.label": "Close Instance",
|
"commands.closeInstance.label": "Close Tab",
|
||||||
"commands.closeInstance.description": "Stop current instance's server",
|
"commands.closeInstance.description": "Close the current top-level tab",
|
||||||
"commands.closeInstance.keywords": "stop, quit, close",
|
"commands.closeInstance.keywords": "stop, quit, close, tab",
|
||||||
|
|
||||||
"commands.nextInstance.label": "Next Instance",
|
"commands.nextInstance.label": "Next Tab",
|
||||||
"commands.nextInstance.description": "Cycle to next instance tab",
|
"commands.nextInstance.description": "Cycle to the next top-level tab",
|
||||||
"commands.nextInstance.keywords": "switch, navigate",
|
"commands.nextInstance.keywords": "switch, navigate, tab",
|
||||||
|
|
||||||
"commands.previousInstance.label": "Previous Instance",
|
"commands.previousInstance.label": "Previous Tab",
|
||||||
"commands.previousInstance.description": "Cycle to previous instance tab",
|
"commands.previousInstance.description": "Cycle to the previous top-level tab",
|
||||||
"commands.previousInstance.keywords": "switch, navigate",
|
"commands.previousInstance.keywords": "switch, navigate, tab",
|
||||||
|
|
||||||
"commands.newSession.label": "New Session",
|
"commands.newSession.label": "New Session",
|
||||||
"commands.newSession.description": "Create a new parent session",
|
"commands.newSession.description": "Create a new parent session",
|
||||||
|
|||||||
@@ -20,6 +20,9 @@ export const folderSelectionMessages = {
|
|||||||
"folderSelection.browse.subtitle": "Select any folder on your computer",
|
"folderSelection.browse.subtitle": "Select any folder on your computer",
|
||||||
"folderSelection.browse.button": "Browse Folders",
|
"folderSelection.browse.button": "Browse Folders",
|
||||||
"folderSelection.browse.buttonOpening": "Opening...",
|
"folderSelection.browse.buttonOpening": "Opening...",
|
||||||
|
"folderSelection.actions.title": "Open Folder or Connect Server",
|
||||||
|
"folderSelection.actions.subtitle": "Open local folder or connect to a CodeNomad server",
|
||||||
|
"folderSelection.actions.connectButton": "Connect CodeNomad Server",
|
||||||
|
|
||||||
"folderSelection.advancedSettings": "Advanced Settings",
|
"folderSelection.advancedSettings": "Advanced Settings",
|
||||||
"folderSelection.opencode": "OpenCode",
|
"folderSelection.opencode": "OpenCode",
|
||||||
@@ -39,4 +42,32 @@ export const folderSelectionMessages = {
|
|||||||
|
|
||||||
"folderSelection.dialog.title": "Select Workspace",
|
"folderSelection.dialog.title": "Select Workspace",
|
||||||
"folderSelection.dialog.description": "Select workspace to start coding.",
|
"folderSelection.dialog.description": "Select workspace to start coding.",
|
||||||
|
|
||||||
|
"folderSelection.tabs.local": "Local Folders",
|
||||||
|
"folderSelection.tabs.servers": "Servers",
|
||||||
|
"folderSelection.servers.title": "Saved Servers",
|
||||||
|
"folderSelection.servers.subtitle": "Open a saved remote CodeNomad server in a new window",
|
||||||
|
"folderSelection.servers.count": "{count} Servers",
|
||||||
|
"folderSelection.servers.empty.title": "No Saved Servers",
|
||||||
|
"folderSelection.servers.empty.description": "Add a remote server to reconnect quickly from this device",
|
||||||
|
"folderSelection.servers.connectTitle": "Connect to Server",
|
||||||
|
"folderSelection.servers.connectSubtitle": "Save a remote CodeNomad server and open it in a new window",
|
||||||
|
"folderSelection.servers.connectButton": "Connect to Server",
|
||||||
|
"folderSelection.servers.remove": "Remove saved server",
|
||||||
|
"folderSelection.servers.skipTls": "Self-signed TLS",
|
||||||
|
"folderSelection.servers.errorTitle": "Remote Connection Failed",
|
||||||
|
"folderSelection.servers.dialog.title": "Connect to Server",
|
||||||
|
"folderSelection.servers.dialog.description": "Add a remote CodeNomad server and optionally open it right away.",
|
||||||
|
"folderSelection.servers.dialog.name": "Server name",
|
||||||
|
"folderSelection.servers.dialog.namePlaceholder": "Production Server",
|
||||||
|
"folderSelection.servers.dialog.url": "Server URL",
|
||||||
|
"folderSelection.servers.dialog.urlPlaceholder": "https://server.example.com",
|
||||||
|
"folderSelection.servers.dialog.skipTls": "Skip TLS verification for self-signed certificates.",
|
||||||
|
"folderSelection.servers.dialog.cancel": "Cancel",
|
||||||
|
"folderSelection.servers.dialog.save": "Save",
|
||||||
|
"folderSelection.servers.dialog.connect": "Connect",
|
||||||
|
"folderSelection.servers.dialog.connecting": "Connecting...",
|
||||||
|
"folderSelection.servers.dialog.errorRequired": "Server name and URL are required.",
|
||||||
|
"folderSelection.servers.dialog.errorConnect": "Could not connect to the remote server.",
|
||||||
|
"folderSelection.sidecars.button": "Open SideCar",
|
||||||
} as const
|
} as const
|
||||||
|
|||||||
@@ -26,7 +26,6 @@ export const instanceMessages = {
|
|||||||
|
|
||||||
"instanceShell.leftPanel.sessionsTitle": "Sessions",
|
"instanceShell.leftPanel.sessionsTitle": "Sessions",
|
||||||
"instanceShell.leftPanel.instanceInfo": "Instance Info",
|
"instanceShell.leftPanel.instanceInfo": "Instance Info",
|
||||||
|
|
||||||
"instanceShell.leftDrawer.pin": "Pin left drawer",
|
"instanceShell.leftDrawer.pin": "Pin left drawer",
|
||||||
"instanceShell.leftDrawer.unpin": "Unpin left drawer",
|
"instanceShell.leftDrawer.unpin": "Unpin left drawer",
|
||||||
"instanceShell.leftDrawer.toggle.pinned": "Left drawer pinned",
|
"instanceShell.leftDrawer.toggle.pinned": "Left drawer pinned",
|
||||||
@@ -107,6 +106,8 @@ export const instanceMessages = {
|
|||||||
"instanceShell.rightPanel.actions.refreshDirty.cancelLabel": "Cancel",
|
"instanceShell.rightPanel.actions.refreshDirty.cancelLabel": "Cancel",
|
||||||
"instanceShell.rightPanel.toast.saveSuccess": "File saved successfully",
|
"instanceShell.rightPanel.toast.saveSuccess": "File saved successfully",
|
||||||
"instanceShell.rightPanel.toast.saveError": "Failed to save file",
|
"instanceShell.rightPanel.toast.saveError": "Failed to save file",
|
||||||
|
"instanceShell.rightPanel.sections.yoloMode": "Yolo Mode",
|
||||||
|
"instanceShell.rightPanel.sections.yoloMode.tooltip": "Automatically approves permission requests for the current session. Use it only when you trust the tools being run.",
|
||||||
"instanceShell.rightPanel.sections.sessionChanges": "Session Changes",
|
"instanceShell.rightPanel.sections.sessionChanges": "Session Changes",
|
||||||
"instanceShell.rightPanel.sections.sessionChanges.tooltip": "Files modified in the current session. Shows additions and deletions for each file.",
|
"instanceShell.rightPanel.sections.sessionChanges.tooltip": "Files modified in the current session. Shows additions and deletions for each file.",
|
||||||
"instanceShell.rightPanel.sections.plan": "Plan",
|
"instanceShell.rightPanel.sections.plan": "Plan",
|
||||||
@@ -150,6 +151,12 @@ export const instanceMessages = {
|
|||||||
"instanceShell.plan.noSessionSelected": "Select a session to view plan.",
|
"instanceShell.plan.noSessionSelected": "Select a session to view plan.",
|
||||||
"instanceShell.plan.empty": "Nothing planned yet.",
|
"instanceShell.plan.empty": "Nothing planned yet.",
|
||||||
|
|
||||||
|
"instanceShell.yoloMode.noSessionSelected": "Select a session to configure Yolo mode.",
|
||||||
|
"instanceShell.yoloMode.title": "Yolo mode",
|
||||||
|
"instanceShell.yoloMode.description": "Automatically approve permission requests for this session. Disabled by default.",
|
||||||
|
"instanceShell.yoloMode.badge": "Yolo mode",
|
||||||
|
"instanceShell.yoloMode.badgeAriaLabel": "Yolo mode enabled",
|
||||||
|
|
||||||
"instanceShell.backgroundProcesses.empty": "No background processes.",
|
"instanceShell.backgroundProcesses.empty": "No background processes.",
|
||||||
"instanceShell.backgroundProcesses.status": "Status: {status}",
|
"instanceShell.backgroundProcesses.status": "Status: {status}",
|
||||||
"instanceShell.backgroundProcesses.output": "Output: {sizeKb}KB",
|
"instanceShell.backgroundProcesses.output": "Output: {sizeKb}KB",
|
||||||
|
|||||||
@@ -41,6 +41,8 @@ export const remoteAccessMessages = {
|
|||||||
"remoteAccess.sections.addresses.help": "Launch or scan from another machine to hand over control.",
|
"remoteAccess.sections.addresses.help": "Launch or scan from another machine to hand over control.",
|
||||||
"remoteAccess.addresses.loading": "Loading addresses…",
|
"remoteAccess.addresses.loading": "Loading addresses…",
|
||||||
"remoteAccess.addresses.none": "No addresses available yet.",
|
"remoteAccess.addresses.none": "No addresses available yet.",
|
||||||
|
"remoteAccess.addresses.actions.showOther": "Show {count} other addresses",
|
||||||
|
"remoteAccess.addresses.actions.hideOther": "Hide other addresses",
|
||||||
"remoteAccess.address.scope.network": "Network",
|
"remoteAccess.address.scope.network": "Network",
|
||||||
"remoteAccess.address.scope.loopback": "Loopback",
|
"remoteAccess.address.scope.loopback": "Loopback",
|
||||||
"remoteAccess.address.scope.internal": "Internal",
|
"remoteAccess.address.scope.internal": "Internal",
|
||||||
|
|||||||
@@ -15,6 +15,10 @@ export const sessionMessages = {
|
|||||||
"sessionList.status.working": "Working",
|
"sessionList.status.working": "Working",
|
||||||
"sessionList.status.compacting": "Compacting",
|
"sessionList.status.compacting": "Compacting",
|
||||||
"sessionList.status.idle": "Idle",
|
"sessionList.status.idle": "Idle",
|
||||||
|
"sessionList.status.retrying": "Retrying",
|
||||||
|
"sessionList.status.retryingIn": "Retrying in {seconds}s",
|
||||||
|
"sessionList.status.retryTooltip": "{message} (Attempt {attempt})",
|
||||||
|
"sessionList.status.retryToast": "{countdown}: {message} (Attempt {attempt})",
|
||||||
"sessionList.status.needsPermission": "Needs Permission",
|
"sessionList.status.needsPermission": "Needs Permission",
|
||||||
"sessionList.status.needsInput": "Needs Input",
|
"sessionList.status.needsInput": "Needs Input",
|
||||||
"sessionList.expand.collapseAriaLabel": "Collapse session",
|
"sessionList.expand.collapseAriaLabel": "Collapse session",
|
||||||
@@ -25,12 +29,15 @@ export const sessionMessages = {
|
|||||||
"sessionList.actions.newSession.title": "New session",
|
"sessionList.actions.newSession.title": "New session",
|
||||||
"sessionList.actions.copyId.ariaLabel": "Copy session ID",
|
"sessionList.actions.copyId.ariaLabel": "Copy session ID",
|
||||||
"sessionList.actions.copyId.title": "Copy session ID",
|
"sessionList.actions.copyId.title": "Copy session ID",
|
||||||
|
"sessionList.actions.reload.ariaLabel": "Reload session",
|
||||||
|
"sessionList.actions.reload.title": "Reload session",
|
||||||
"sessionList.actions.rename.ariaLabel": "Rename session",
|
"sessionList.actions.rename.ariaLabel": "Rename session",
|
||||||
"sessionList.actions.rename.title": "Rename session",
|
"sessionList.actions.rename.title": "Rename session",
|
||||||
"sessionList.actions.delete.ariaLabel": "Delete session",
|
"sessionList.actions.delete.ariaLabel": "Delete session",
|
||||||
"sessionList.actions.delete.title": "Delete session",
|
"sessionList.actions.delete.title": "Delete session",
|
||||||
"sessionList.copyId.success": "Session ID copied",
|
"sessionList.copyId.success": "Session ID copied",
|
||||||
"sessionList.copyId.error": "Unable to copy session ID",
|
"sessionList.copyId.error": "Unable to copy session ID",
|
||||||
|
"sessionList.reload.error": "Unable to reload session",
|
||||||
"sessionList.delete.error": "Unable to delete session",
|
"sessionList.delete.error": "Unable to delete session",
|
||||||
"sessionList.delete.title": "Delete session",
|
"sessionList.delete.title": "Delete session",
|
||||||
"sessionList.delete.confirmMessage": "Delete \"{label}\"? This cannot be undone.",
|
"sessionList.delete.confirmMessage": "Delete \"{label}\"? This cannot be undone.",
|
||||||
|
|||||||
@@ -113,6 +113,15 @@ export const settingsMessages = {
|
|||||||
"settings.section.opencode.subtitle": "Choose the OpenCode binary and environment used for new instances.",
|
"settings.section.opencode.subtitle": "Choose the OpenCode binary and environment used for new instances.",
|
||||||
"settings.opencode.runtime.title": "Runtime",
|
"settings.opencode.runtime.title": "Runtime",
|
||||||
"settings.opencode.runtime.subtitle": "Configure which OpenCode binary new instances launch with.",
|
"settings.opencode.runtime.subtitle": "Configure which OpenCode binary new instances launch with.",
|
||||||
|
"settings.opencode.logLevel.title": "OpenCode Log Level",
|
||||||
|
"settings.opencode.logLevel.subtitle": "Control the log verbosity used when launching new OpenCode instances.",
|
||||||
|
"settings.opencode.logLevel.selector.title": "Default log level",
|
||||||
|
"settings.opencode.logLevel.selector.subtitle": "Choose how verbose new OpenCode instances should be.",
|
||||||
|
"settings.opencode.logLevel.option.debug": "Debug",
|
||||||
|
"settings.opencode.logLevel.option.info": "Info",
|
||||||
|
"settings.opencode.logLevel.option.warn": "Warn",
|
||||||
|
"settings.opencode.logLevel.option.error": "Error",
|
||||||
|
|
||||||
|
|
||||||
"settings.appearance.behavior.title": "Interaction",
|
"settings.appearance.behavior.title": "Interaction",
|
||||||
"settings.appearance.behavior.subtitle": "Message, diff, and input defaults.",
|
"settings.appearance.behavior.subtitle": "Message, diff, and input defaults.",
|
||||||
@@ -186,4 +195,40 @@ export const settingsMessages = {
|
|||||||
"settings.speech.save.saved": "Saved",
|
"settings.speech.save.saved": "Saved",
|
||||||
"settings.speech.save.unsaved": "Unsaved changes",
|
"settings.speech.save.unsaved": "Unsaved changes",
|
||||||
"settings.speech.save.error": "Save failed",
|
"settings.speech.save.error": "Save failed",
|
||||||
|
"settings.nav.sidecars": "SideCars",
|
||||||
|
"settings.section.sidecars.eyebrow": "Server services",
|
||||||
|
"settings.section.sidecars.title": "SideCars",
|
||||||
|
"settings.section.sidecars.subtitle": "Configure local services listening on fixed ports that the server can proxy into tabs.",
|
||||||
|
"sidecars.form.name": "Name",
|
||||||
|
"sidecars.form.validation": "Enter a valid SideCar name and port.",
|
||||||
|
"sidecars.form.port": "Port",
|
||||||
|
"sidecars.form.insecure": "Use HTTP",
|
||||||
|
"sidecars.form.protocol": "Protocol",
|
||||||
|
"sidecars.form.protocol.help": "Choose how the proxy should connect to the local service.",
|
||||||
|
"sidecars.form.protocol.https": "HTTPS",
|
||||||
|
"sidecars.form.protocol.http": "HTTP",
|
||||||
|
"sidecars.form.prefixMode": "Prefix mode",
|
||||||
|
"sidecars.form.prefixMode.help": "Choose whether the SideCar receives the public /sidecars path prefix.",
|
||||||
|
"sidecars.form.prefixMode.strip": "Strip prefix",
|
||||||
|
"sidecars.form.prefixMode.preserve": "Preserve prefix",
|
||||||
|
"sidecars.form.add": "Add SideCar",
|
||||||
|
"sidecars.kind.port": "Port",
|
||||||
|
"sidecars.status.running": "Running",
|
||||||
|
"sidecars.status.stopped": "Stopped",
|
||||||
|
"sidecars.basePath": "Base path",
|
||||||
|
"sidecars.settings.listTitle": "Configured SideCars",
|
||||||
|
"sidecars.settings.listSubtitle": "Review the port-based SideCars available in the picker.",
|
||||||
|
"sidecars.settings.empty": "No SideCars configured yet.",
|
||||||
|
"sidecars.picker.title": "Open SideCar",
|
||||||
|
"sidecars.picker.loading": "Loading SideCars...",
|
||||||
|
"sidecars.picker.subtitle": "Choose an available SideCar to open in a new tab.",
|
||||||
|
"sidecars.picker.empty": "No port-based SideCars are available yet.",
|
||||||
|
"sidecars.picker.close": "Close",
|
||||||
|
"sidecars.open.errorTitle": "Unable to open SideCar",
|
||||||
|
"sidecars.open.notFound": "SideCar not found.",
|
||||||
|
"sidecars.open.notRunning": "SideCar is not reachable on its configured port.",
|
||||||
|
"sidecars.back": "Back",
|
||||||
|
"sidecars.refresh": "Refresh",
|
||||||
|
"sidecars.path": "Path",
|
||||||
|
"sidecars.go": "Go",
|
||||||
} as const
|
} as const
|
||||||
|
|||||||
@@ -18,6 +18,11 @@ export const toolCallMessages = {
|
|||||||
"toolCall.diff.viewMode.ariaLabel": "Diff view mode",
|
"toolCall.diff.viewMode.ariaLabel": "Diff view mode",
|
||||||
"toolCall.diff.viewMode.split": "Split",
|
"toolCall.diff.viewMode.split": "Split",
|
||||||
"toolCall.diff.viewMode.unified": "Unified",
|
"toolCall.diff.viewMode.unified": "Unified",
|
||||||
|
"toolCall.diff.switchToSplit": "Switch to split view",
|
||||||
|
"toolCall.diff.switchToUnified": "Switch to unified view",
|
||||||
|
"toolCall.diff.enableWordWrap": "Enable word wrap",
|
||||||
|
"toolCall.diff.disableWordWrap": "Disable word wrap",
|
||||||
|
"toolCall.diff.copyPatch": "Copy patch",
|
||||||
|
|
||||||
"toolCall.diagnostics.title": "Diagnostics",
|
"toolCall.diagnostics.title": "Diagnostics",
|
||||||
"toolCall.diagnostics.ariaLabel": "Diagnostics",
|
"toolCall.diagnostics.ariaLabel": "Diagnostics",
|
||||||
|
|||||||
@@ -15,17 +15,17 @@ export const commandMessages = {
|
|||||||
"commands.newInstance.description": "Abrir el selector de carpetas para crear una nueva instancia",
|
"commands.newInstance.description": "Abrir el selector de carpetas para crear una nueva instancia",
|
||||||
"commands.newInstance.keywords": "carpeta, proyecto, workspace",
|
"commands.newInstance.keywords": "carpeta, proyecto, workspace",
|
||||||
|
|
||||||
"commands.closeInstance.label": "Cerrar instancia",
|
"commands.closeInstance.label": "Cerrar pestaña",
|
||||||
"commands.closeInstance.description": "Detener el servidor de la instancia actual",
|
"commands.closeInstance.description": "Cerrar la pestaña superior actual",
|
||||||
"commands.closeInstance.keywords": "detener, salir, cerrar",
|
"commands.closeInstance.keywords": "detener, salir, cerrar, pestaña",
|
||||||
|
|
||||||
"commands.nextInstance.label": "Siguiente instancia",
|
"commands.nextInstance.label": "Siguiente pestaña",
|
||||||
"commands.nextInstance.description": "Cambiar a la siguiente pestaña de instancia",
|
"commands.nextInstance.description": "Cambiar a la siguiente pestaña superior",
|
||||||
"commands.nextInstance.keywords": "cambiar, navegar",
|
"commands.nextInstance.keywords": "cambiar, navegar, pestaña",
|
||||||
|
|
||||||
"commands.previousInstance.label": "Instancia anterior",
|
"commands.previousInstance.label": "Pestaña anterior",
|
||||||
"commands.previousInstance.description": "Cambiar a la pestaña de instancia anterior",
|
"commands.previousInstance.description": "Cambiar a la pestaña superior anterior",
|
||||||
"commands.previousInstance.keywords": "cambiar, navegar",
|
"commands.previousInstance.keywords": "cambiar, navegar, pestaña",
|
||||||
|
|
||||||
"commands.newSession.label": "Nueva sesión",
|
"commands.newSession.label": "Nueva sesión",
|
||||||
"commands.newSession.description": "Crear una nueva sesión principal",
|
"commands.newSession.description": "Crear una nueva sesión principal",
|
||||||
|
|||||||
@@ -2,35 +2,38 @@ export const folderSelectionMessages = {
|
|||||||
"folderSelection.language.ariaLabel": "Idioma",
|
"folderSelection.language.ariaLabel": "Idioma",
|
||||||
|
|
||||||
"folderSelection.logoAlt": "Logo de CodeNomad",
|
"folderSelection.logoAlt": "Logo de CodeNomad",
|
||||||
"folderSelection.tagline": "Selecciona una carpeta para empezar a programar con IA",
|
"folderSelection.tagline": "Selecciona una carpeta para empezar a programar con AI",
|
||||||
|
|
||||||
"folderSelection.links.github": "GitHub de CodeNomad",
|
"folderSelection.links.github": "GitHub de CodeNomad",
|
||||||
"folderSelection.links.githubStars": "Estrellas de CodeNomad en GitHub",
|
"folderSelection.links.githubStars": "Estrellas de GitHub de CodeNomad",
|
||||||
"folderSelection.links.discord": "Discord de CodeNomad",
|
"folderSelection.links.discord": "Discord de CodeNomad",
|
||||||
|
|
||||||
"folderSelection.empty.title": "No hay carpetas recientes",
|
"folderSelection.empty.title": "No hay carpetas recientes",
|
||||||
"folderSelection.empty.description": "Explora una carpeta para comenzar",
|
"folderSelection.empty.description": "Busca una carpeta para comenzar",
|
||||||
|
|
||||||
"folderSelection.recent.title": "Carpetas recientes",
|
"folderSelection.recent.title": "Carpetas recientes",
|
||||||
"folderSelection.recent.subtitle.one": "{count} carpeta disponible",
|
"folderSelection.recent.subtitle.one": "{count} carpeta disponible",
|
||||||
"folderSelection.recent.subtitle.other": "{count} carpetas disponibles",
|
"folderSelection.recent.subtitle.other": "{count} carpetas disponibles",
|
||||||
"folderSelection.recent.remove": "Quitar de recientes",
|
"folderSelection.recent.remove": "Eliminar de recientes",
|
||||||
|
|
||||||
"folderSelection.browse.title": "Explorar carpetas",
|
"folderSelection.browse.title": "Buscar carpeta",
|
||||||
"folderSelection.browse.subtitle": "Selecciona cualquier carpeta en tu ordenador",
|
"folderSelection.browse.subtitle": "Selecciona cualquier carpeta en tu ordenador",
|
||||||
"folderSelection.browse.button": "Explorar carpetas",
|
"folderSelection.browse.button": "Buscar carpetas",
|
||||||
"folderSelection.browse.buttonOpening": "Abriendo...",
|
"folderSelection.browse.buttonOpening": "Abriendo...",
|
||||||
|
"folderSelection.actions.title": "Abrir carpeta o conectar servidor",
|
||||||
|
"folderSelection.actions.subtitle": "Abre una carpeta local o conéctate a un servidor de CodeNomad",
|
||||||
|
"folderSelection.actions.connectButton": "Conectar servidor CodeNomad",
|
||||||
|
|
||||||
"folderSelection.advancedSettings": "Configuración avanzada",
|
"folderSelection.advancedSettings": "Configuración avanzada",
|
||||||
"folderSelection.opencode": "OpenCode",
|
"folderSelection.opencode": "OpenCode",
|
||||||
|
|
||||||
"folderSelection.hints.navigate": "Navegar",
|
"folderSelection.hints.navigate": "Navegar",
|
||||||
"folderSelection.hints.select": "Seleccionar",
|
"folderSelection.hints.select": "Seleccionar",
|
||||||
"folderSelection.hints.remove": "Quitar",
|
"folderSelection.hints.remove": "Eliminar",
|
||||||
"folderSelection.hints.browse": "Explorar",
|
"folderSelection.hints.browse": "Buscar",
|
||||||
|
|
||||||
"folderSelection.loading.title": "Iniciando instancia...",
|
"folderSelection.loading.title": "Iniciando instancia...",
|
||||||
"folderSelection.loading.subtitle": "Espera un momento mientras preparamos tu workspace.",
|
"folderSelection.loading.subtitle": "Espera mientras preparamos tu espacio de trabajo.",
|
||||||
|
|
||||||
"folderSelection.drop.title": "Suelta una carpeta para abrirla",
|
"folderSelection.drop.title": "Suelta una carpeta para abrirla",
|
||||||
"folderSelection.drop.subtitle": "Inicia una nueva instancia en la carpeta soltada.",
|
"folderSelection.drop.subtitle": "Inicia una nueva instancia en la carpeta soltada.",
|
||||||
@@ -39,4 +42,32 @@ export const folderSelectionMessages = {
|
|||||||
|
|
||||||
"folderSelection.dialog.title": "Seleccionar workspace",
|
"folderSelection.dialog.title": "Seleccionar workspace",
|
||||||
"folderSelection.dialog.description": "Selecciona un workspace para empezar a programar.",
|
"folderSelection.dialog.description": "Selecciona un workspace para empezar a programar.",
|
||||||
|
|
||||||
|
"folderSelection.tabs.local": "Carpetas locales",
|
||||||
|
"folderSelection.tabs.servers": "Servidores",
|
||||||
|
"folderSelection.servers.title": "Servidores guardados",
|
||||||
|
"folderSelection.servers.subtitle": "Abre un servidor remoto de CodeNomad guardado en una ventana nueva",
|
||||||
|
"folderSelection.servers.count": "{count} servidores",
|
||||||
|
"folderSelection.servers.empty.title": "No hay servidores guardados",
|
||||||
|
"folderSelection.servers.empty.description": "Añade un servidor remoto para volver a conectarte rápidamente desde este dispositivo",
|
||||||
|
"folderSelection.servers.connectTitle": "Conectar a un servidor",
|
||||||
|
"folderSelection.servers.connectSubtitle": "Guarda un servidor remoto de CodeNomad y ábrelo en una ventana nueva",
|
||||||
|
"folderSelection.servers.connectButton": "Conectar a un servidor",
|
||||||
|
"folderSelection.servers.remove": "Eliminar servidor guardado",
|
||||||
|
"folderSelection.servers.skipTls": "TLS autofirmado",
|
||||||
|
"folderSelection.servers.errorTitle": "Falló la conexión remota",
|
||||||
|
"folderSelection.servers.dialog.title": "Conectar a un servidor",
|
||||||
|
"folderSelection.servers.dialog.description": "Añade un servidor remoto de CodeNomad y ábrelo ahora si quieres.",
|
||||||
|
"folderSelection.servers.dialog.name": "Nombre del servidor",
|
||||||
|
"folderSelection.servers.dialog.namePlaceholder": "Servidor de producción",
|
||||||
|
"folderSelection.servers.dialog.url": "URL del servidor",
|
||||||
|
"folderSelection.servers.dialog.urlPlaceholder": "https://server.example.com",
|
||||||
|
"folderSelection.servers.dialog.skipTls": "Omitir la verificación TLS para certificados autofirmados.",
|
||||||
|
"folderSelection.servers.dialog.cancel": "Cancelar",
|
||||||
|
"folderSelection.servers.dialog.save": "Guardar",
|
||||||
|
"folderSelection.servers.dialog.connect": "Conectar",
|
||||||
|
"folderSelection.servers.dialog.connecting": "Conectando...",
|
||||||
|
"folderSelection.servers.dialog.errorRequired": "El nombre y la URL del servidor son obligatorios.",
|
||||||
|
"folderSelection.servers.dialog.errorConnect": "No se pudo conectar al servidor remoto.",
|
||||||
|
"folderSelection.sidecars.button": "Open SideCar",
|
||||||
} as const
|
} as const
|
||||||
|
|||||||
@@ -26,7 +26,6 @@ export const instanceMessages = {
|
|||||||
|
|
||||||
"instanceShell.leftPanel.sessionsTitle": "Sesiones",
|
"instanceShell.leftPanel.sessionsTitle": "Sesiones",
|
||||||
"instanceShell.leftPanel.instanceInfo": "Info de la instancia",
|
"instanceShell.leftPanel.instanceInfo": "Info de la instancia",
|
||||||
|
|
||||||
"instanceShell.leftDrawer.pin": "Fijar panel izquierdo",
|
"instanceShell.leftDrawer.pin": "Fijar panel izquierdo",
|
||||||
"instanceShell.leftDrawer.unpin": "Desfijar panel izquierdo",
|
"instanceShell.leftDrawer.unpin": "Desfijar panel izquierdo",
|
||||||
"instanceShell.leftDrawer.toggle.pinned": "Panel izquierdo fijado",
|
"instanceShell.leftDrawer.toggle.pinned": "Panel izquierdo fijado",
|
||||||
@@ -107,6 +106,8 @@ export const instanceMessages = {
|
|||||||
"instanceShell.rightPanel.actions.refreshDirty.cancelLabel": "Cancelar",
|
"instanceShell.rightPanel.actions.refreshDirty.cancelLabel": "Cancelar",
|
||||||
"instanceShell.rightPanel.toast.saveSuccess": "Archivo guardado exitosamente",
|
"instanceShell.rightPanel.toast.saveSuccess": "Archivo guardado exitosamente",
|
||||||
"instanceShell.rightPanel.toast.saveError": "Error al guardar el archivo",
|
"instanceShell.rightPanel.toast.saveError": "Error al guardar el archivo",
|
||||||
|
"instanceShell.rightPanel.sections.yoloMode": "Modo yolo",
|
||||||
|
"instanceShell.rightPanel.sections.yoloMode.tooltip": "Aprueba automaticamente las solicitudes de permiso de la sesion actual. Usalo solo si confias en las herramientas que se estan ejecutando.",
|
||||||
"instanceShell.rightPanel.sections.sessionChanges": "Cambios de sesión",
|
"instanceShell.rightPanel.sections.sessionChanges": "Cambios de sesión",
|
||||||
"instanceShell.rightPanel.sections.sessionChanges.tooltip": "Archivos modificados en la sesión actual. Muestra las adiciones y eliminaciones de cada archivo.",
|
"instanceShell.rightPanel.sections.sessionChanges.tooltip": "Archivos modificados en la sesión actual. Muestra las adiciones y eliminaciones de cada archivo.",
|
||||||
"instanceShell.rightPanel.sections.plan": "Plan",
|
"instanceShell.rightPanel.sections.plan": "Plan",
|
||||||
@@ -140,6 +141,12 @@ export const instanceMessages = {
|
|||||||
"instanceShell.plan.noSessionSelected": "Selecciona una sesión para ver el plan.",
|
"instanceShell.plan.noSessionSelected": "Selecciona una sesión para ver el plan.",
|
||||||
"instanceShell.plan.empty": "Aún no hay nada planificado.",
|
"instanceShell.plan.empty": "Aún no hay nada planificado.",
|
||||||
|
|
||||||
|
"instanceShell.yoloMode.noSessionSelected": "Selecciona una sesion para configurar el modo yolo.",
|
||||||
|
"instanceShell.yoloMode.title": "Modo yolo",
|
||||||
|
"instanceShell.yoloMode.description": "Aprueba automaticamente las solicitudes de permiso de esta sesion. Esta desactivado por defecto.",
|
||||||
|
"instanceShell.yoloMode.badge": "Modo yolo",
|
||||||
|
"instanceShell.yoloMode.badgeAriaLabel": "Modo yolo activado",
|
||||||
|
|
||||||
"instanceShell.backgroundProcesses.empty": "No hay procesos en segundo plano.",
|
"instanceShell.backgroundProcesses.empty": "No hay procesos en segundo plano.",
|
||||||
"instanceShell.backgroundProcesses.status": "Estado: {status}",
|
"instanceShell.backgroundProcesses.status": "Estado: {status}",
|
||||||
"instanceShell.backgroundProcesses.output": "Salida: {sizeKb} KB",
|
"instanceShell.backgroundProcesses.output": "Salida: {sizeKb} KB",
|
||||||
|
|||||||
@@ -41,6 +41,8 @@ export const remoteAccessMessages = {
|
|||||||
"remoteAccess.sections.addresses.help": "Abre o escanea desde otra máquina para transferir el control.",
|
"remoteAccess.sections.addresses.help": "Abre o escanea desde otra máquina para transferir el control.",
|
||||||
"remoteAccess.addresses.loading": "Cargando direcciones…",
|
"remoteAccess.addresses.loading": "Cargando direcciones…",
|
||||||
"remoteAccess.addresses.none": "Aún no hay direcciones disponibles.",
|
"remoteAccess.addresses.none": "Aún no hay direcciones disponibles.",
|
||||||
|
"remoteAccess.addresses.actions.showOther": "Mostrar {count} direcciones más",
|
||||||
|
"remoteAccess.addresses.actions.hideOther": "Ocultar otras direcciones",
|
||||||
"remoteAccess.address.scope.network": "Red",
|
"remoteAccess.address.scope.network": "Red",
|
||||||
"remoteAccess.address.scope.loopback": "Loopback",
|
"remoteAccess.address.scope.loopback": "Loopback",
|
||||||
"remoteAccess.address.scope.internal": "Interna",
|
"remoteAccess.address.scope.internal": "Interna",
|
||||||
|
|||||||
@@ -15,6 +15,10 @@ export const sessionMessages = {
|
|||||||
"sessionList.status.working": "Trabajando",
|
"sessionList.status.working": "Trabajando",
|
||||||
"sessionList.status.compacting": "Compactando",
|
"sessionList.status.compacting": "Compactando",
|
||||||
"sessionList.status.idle": "Inactiva",
|
"sessionList.status.idle": "Inactiva",
|
||||||
|
"sessionList.status.retrying": "Reintentando",
|
||||||
|
"sessionList.status.retryingIn": "Reintentando en {seconds}s",
|
||||||
|
"sessionList.status.retryTooltip": "{message} (Intento {attempt})",
|
||||||
|
"sessionList.status.retryToast": "{countdown}: {message} (Intento {attempt})",
|
||||||
"sessionList.status.needsPermission": "Requiere permiso",
|
"sessionList.status.needsPermission": "Requiere permiso",
|
||||||
"sessionList.status.needsInput": "Requiere entrada",
|
"sessionList.status.needsInput": "Requiere entrada",
|
||||||
"sessionList.expand.collapseAriaLabel": "Colapsar sesión",
|
"sessionList.expand.collapseAriaLabel": "Colapsar sesión",
|
||||||
@@ -25,12 +29,15 @@ export const sessionMessages = {
|
|||||||
"sessionList.actions.newSession.title": "Nueva sesión",
|
"sessionList.actions.newSession.title": "Nueva sesión",
|
||||||
"sessionList.actions.copyId.ariaLabel": "Copiar ID de sesión",
|
"sessionList.actions.copyId.ariaLabel": "Copiar ID de sesión",
|
||||||
"sessionList.actions.copyId.title": "Copiar ID de sesión",
|
"sessionList.actions.copyId.title": "Copiar ID de sesión",
|
||||||
|
"sessionList.actions.reload.ariaLabel": "Recargar sesión",
|
||||||
|
"sessionList.actions.reload.title": "Recargar sesión",
|
||||||
"sessionList.actions.rename.ariaLabel": "Renombrar sesión",
|
"sessionList.actions.rename.ariaLabel": "Renombrar sesión",
|
||||||
"sessionList.actions.rename.title": "Renombrar sesión",
|
"sessionList.actions.rename.title": "Renombrar sesión",
|
||||||
"sessionList.actions.delete.ariaLabel": "Eliminar sesión",
|
"sessionList.actions.delete.ariaLabel": "Eliminar sesión",
|
||||||
"sessionList.actions.delete.title": "Eliminar sesión",
|
"sessionList.actions.delete.title": "Eliminar sesión",
|
||||||
"sessionList.copyId.success": "ID de sesión copiado",
|
"sessionList.copyId.success": "ID de sesión copiado",
|
||||||
"sessionList.copyId.error": "No se pudo copiar el ID de sesión",
|
"sessionList.copyId.error": "No se pudo copiar el ID de sesión",
|
||||||
|
"sessionList.reload.error": "No se pudo recargar la sesión",
|
||||||
"sessionList.delete.error": "No se pudo eliminar la sesión",
|
"sessionList.delete.error": "No se pudo eliminar la sesión",
|
||||||
"sessionList.delete.title": "Eliminar sesión",
|
"sessionList.delete.title": "Eliminar sesión",
|
||||||
"sessionList.delete.confirmMessage": "¿Eliminar \"{label}\"? Esto no se puede deshacer.",
|
"sessionList.delete.confirmMessage": "¿Eliminar \"{label}\"? Esto no se puede deshacer.",
|
||||||
|
|||||||
@@ -113,6 +113,14 @@ export const settingsMessages = {
|
|||||||
"settings.section.opencode.subtitle": "Choose the OpenCode binary and environment used for new instances.",
|
"settings.section.opencode.subtitle": "Choose the OpenCode binary and environment used for new instances.",
|
||||||
"settings.opencode.runtime.title": "Runtime",
|
"settings.opencode.runtime.title": "Runtime",
|
||||||
"settings.opencode.runtime.subtitle": "Configure which OpenCode binary new instances launch with.",
|
"settings.opencode.runtime.subtitle": "Configure which OpenCode binary new instances launch with.",
|
||||||
|
"settings.opencode.logLevel.title": "Nivel de logs de OpenCode",
|
||||||
|
"settings.opencode.logLevel.subtitle": "Define el nivel de logs usado al iniciar nuevas instancias de OpenCode.",
|
||||||
|
"settings.opencode.logLevel.selector.title": "Verbosidad de logs",
|
||||||
|
"settings.opencode.logLevel.selector.subtitle": "Elige cuanta informacion deben registrar las nuevas instancias de OpenCode.",
|
||||||
|
"settings.opencode.logLevel.option.debug": "Depuracion",
|
||||||
|
"settings.opencode.logLevel.option.info": "Informacion",
|
||||||
|
"settings.opencode.logLevel.option.warn": "Advertencia",
|
||||||
|
"settings.opencode.logLevel.option.error": "Error",
|
||||||
|
|
||||||
"settings.appearance.behavior.title": "Interaccion",
|
"settings.appearance.behavior.title": "Interaccion",
|
||||||
"settings.appearance.behavior.subtitle": "Valores predeterminados de mensajes, diffs y entrada.",
|
"settings.appearance.behavior.subtitle": "Valores predeterminados de mensajes, diffs y entrada.",
|
||||||
@@ -186,4 +194,40 @@ export const settingsMessages = {
|
|||||||
"settings.speech.save.saved": "Guardado",
|
"settings.speech.save.saved": "Guardado",
|
||||||
"settings.speech.save.unsaved": "Cambios sin guardar",
|
"settings.speech.save.unsaved": "Cambios sin guardar",
|
||||||
"settings.speech.save.error": "Error al guardar",
|
"settings.speech.save.error": "Error al guardar",
|
||||||
|
"settings.nav.sidecars": "SideCars",
|
||||||
|
"settings.section.sidecars.eyebrow": "Server services",
|
||||||
|
"settings.section.sidecars.title": "SideCars",
|
||||||
|
"settings.section.sidecars.subtitle": "Configure local services listening on fixed ports that the server can proxy into tabs.",
|
||||||
|
"sidecars.form.name": "Name",
|
||||||
|
"sidecars.form.validation": "Enter a valid SideCar name and port.",
|
||||||
|
"sidecars.form.port": "Port",
|
||||||
|
"sidecars.form.insecure": "Use HTTP",
|
||||||
|
"sidecars.form.protocol": "Protocol",
|
||||||
|
"sidecars.form.protocol.help": "Choose how the proxy should connect to the local service.",
|
||||||
|
"sidecars.form.protocol.https": "HTTPS",
|
||||||
|
"sidecars.form.protocol.http": "HTTP",
|
||||||
|
"sidecars.form.prefixMode": "Prefix mode",
|
||||||
|
"sidecars.form.prefixMode.help": "Choose whether the SideCar receives the public /sidecars path prefix.",
|
||||||
|
"sidecars.form.prefixMode.strip": "Strip prefix",
|
||||||
|
"sidecars.form.prefixMode.preserve": "Preserve prefix",
|
||||||
|
"sidecars.form.add": "Add SideCar",
|
||||||
|
"sidecars.kind.port": "Port",
|
||||||
|
"sidecars.status.running": "Running",
|
||||||
|
"sidecars.status.stopped": "Stopped",
|
||||||
|
"sidecars.basePath": "Base path",
|
||||||
|
"sidecars.settings.listTitle": "Configured SideCars",
|
||||||
|
"sidecars.settings.listSubtitle": "Review the port-based SideCars available in the picker.",
|
||||||
|
"sidecars.settings.empty": "No SideCars configured yet.",
|
||||||
|
"sidecars.picker.title": "Open SideCar",
|
||||||
|
"sidecars.picker.loading": "Loading SideCars...",
|
||||||
|
"sidecars.picker.subtitle": "Choose an available SideCar to open in a new tab.",
|
||||||
|
"sidecars.picker.empty": "No port-based SideCars are available yet.",
|
||||||
|
"sidecars.picker.close": "Close",
|
||||||
|
"sidecars.open.errorTitle": "Unable to open SideCar",
|
||||||
|
"sidecars.open.notFound": "SideCar not found.",
|
||||||
|
"sidecars.open.notRunning": "SideCar is not reachable on its configured port.",
|
||||||
|
"sidecars.back": "Back",
|
||||||
|
"sidecars.refresh": "Refresh",
|
||||||
|
"sidecars.path": "Path",
|
||||||
|
"sidecars.go": "Go",
|
||||||
} as const
|
} as const
|
||||||
|
|||||||
@@ -18,6 +18,11 @@ export const toolCallMessages = {
|
|||||||
"toolCall.diff.viewMode.ariaLabel": "Modo de vista de diff",
|
"toolCall.diff.viewMode.ariaLabel": "Modo de vista de diff",
|
||||||
"toolCall.diff.viewMode.split": "Dividida",
|
"toolCall.diff.viewMode.split": "Dividida",
|
||||||
"toolCall.diff.viewMode.unified": "Unificada",
|
"toolCall.diff.viewMode.unified": "Unificada",
|
||||||
|
"toolCall.diff.switchToSplit": "Cambiar a vista dividida",
|
||||||
|
"toolCall.diff.switchToUnified": "Cambiar a vista unificada",
|
||||||
|
"toolCall.diff.enableWordWrap": "Activar ajuste de línea",
|
||||||
|
"toolCall.diff.disableWordWrap": "Desactivar ajuste de línea",
|
||||||
|
"toolCall.diff.copyPatch": "Copiar patch",
|
||||||
|
|
||||||
"toolCall.diagnostics.title": "Diagnósticos",
|
"toolCall.diagnostics.title": "Diagnósticos",
|
||||||
"toolCall.diagnostics.ariaLabel": "Diagnósticos",
|
"toolCall.diagnostics.ariaLabel": "Diagnósticos",
|
||||||
|
|||||||
@@ -15,17 +15,17 @@ export const commandMessages = {
|
|||||||
"commands.newInstance.description": "Ouvrir le sélecteur de dossiers pour créer une nouvelle instance",
|
"commands.newInstance.description": "Ouvrir le sélecteur de dossiers pour créer une nouvelle instance",
|
||||||
"commands.newInstance.keywords": "dossier, projet, espace de travail",
|
"commands.newInstance.keywords": "dossier, projet, espace de travail",
|
||||||
|
|
||||||
"commands.closeInstance.label": "Fermer l'instance",
|
"commands.closeInstance.label": "Fermer l'onglet",
|
||||||
"commands.closeInstance.description": "Arrêter le serveur de l'instance actuelle",
|
"commands.closeInstance.description": "Fermer l'onglet de premier niveau actuel",
|
||||||
"commands.closeInstance.keywords": "arrêter, quitter, fermer",
|
"commands.closeInstance.keywords": "arrêter, quitter, fermer, onglet",
|
||||||
|
|
||||||
"commands.nextInstance.label": "Instance suivante",
|
"commands.nextInstance.label": "Onglet suivant",
|
||||||
"commands.nextInstance.description": "Passer à l'onglet d'instance suivant",
|
"commands.nextInstance.description": "Passer à l'onglet de premier niveau suivant",
|
||||||
"commands.nextInstance.keywords": "changer, naviguer, suivant",
|
"commands.nextInstance.keywords": "changer, naviguer, suivant, onglet",
|
||||||
|
|
||||||
"commands.previousInstance.label": "Instance précédente",
|
"commands.previousInstance.label": "Onglet précédent",
|
||||||
"commands.previousInstance.description": "Passer à l'onglet d'instance précédent",
|
"commands.previousInstance.description": "Passer à l'onglet de premier niveau précédent",
|
||||||
"commands.previousInstance.keywords": "changer, naviguer, précédent",
|
"commands.previousInstance.keywords": "changer, naviguer, précédent, onglet",
|
||||||
|
|
||||||
"commands.newSession.label": "Nouvelle session",
|
"commands.newSession.label": "Nouvelle session",
|
||||||
"commands.newSession.description": "Créer une nouvelle session parente",
|
"commands.newSession.description": "Créer une nouvelle session parente",
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ export const folderSelectionMessages = {
|
|||||||
"folderSelection.tagline": "Sélectionnez un dossier pour commencer à coder avec l'IA",
|
"folderSelection.tagline": "Sélectionnez un dossier pour commencer à coder avec l'IA",
|
||||||
|
|
||||||
"folderSelection.links.github": "GitHub de CodeNomad",
|
"folderSelection.links.github": "GitHub de CodeNomad",
|
||||||
"folderSelection.links.githubStars": "Stars GitHub de CodeNomad",
|
"folderSelection.links.githubStars": "Étoiles GitHub de CodeNomad",
|
||||||
"folderSelection.links.discord": "Discord de CodeNomad",
|
"folderSelection.links.discord": "Discord de CodeNomad",
|
||||||
|
|
||||||
"folderSelection.empty.title": "Aucun dossier récent",
|
"folderSelection.empty.title": "Aucun dossier récent",
|
||||||
@@ -16,10 +16,13 @@ export const folderSelectionMessages = {
|
|||||||
"folderSelection.recent.subtitle.other": "{count} dossiers disponibles",
|
"folderSelection.recent.subtitle.other": "{count} dossiers disponibles",
|
||||||
"folderSelection.recent.remove": "Retirer des récents",
|
"folderSelection.recent.remove": "Retirer des récents",
|
||||||
|
|
||||||
"folderSelection.browse.title": "Parcourir les dossiers",
|
"folderSelection.browse.title": "Parcourir un dossier",
|
||||||
"folderSelection.browse.subtitle": "Sélectionnez n'importe quel dossier sur votre ordinateur",
|
"folderSelection.browse.subtitle": "Sélectionnez n'importe quel dossier sur votre ordinateur",
|
||||||
"folderSelection.browse.button": "Parcourir les dossiers",
|
"folderSelection.browse.button": "Parcourir les dossiers",
|
||||||
"folderSelection.browse.buttonOpening": "Ouverture...",
|
"folderSelection.browse.buttonOpening": "Ouverture...",
|
||||||
|
"folderSelection.actions.title": "Ouvrir un dossier ou se connecter à un serveur",
|
||||||
|
"folderSelection.actions.subtitle": "Ouvrez un dossier local ou connectez-vous à un serveur CodeNomad",
|
||||||
|
"folderSelection.actions.connectButton": "Se connecter au serveur CodeNomad",
|
||||||
|
|
||||||
"folderSelection.advancedSettings": "Paramètres avancés",
|
"folderSelection.advancedSettings": "Paramètres avancés",
|
||||||
"folderSelection.opencode": "OpenCode",
|
"folderSelection.opencode": "OpenCode",
|
||||||
@@ -39,4 +42,32 @@ export const folderSelectionMessages = {
|
|||||||
|
|
||||||
"folderSelection.dialog.title": "Sélectionner l'espace de travail",
|
"folderSelection.dialog.title": "Sélectionner l'espace de travail",
|
||||||
"folderSelection.dialog.description": "Sélectionnez un espace de travail pour commencer à coder.",
|
"folderSelection.dialog.description": "Sélectionnez un espace de travail pour commencer à coder.",
|
||||||
|
|
||||||
|
"folderSelection.tabs.local": "Dossiers locaux",
|
||||||
|
"folderSelection.tabs.servers": "Serveurs",
|
||||||
|
"folderSelection.servers.title": "Serveurs enregistrés",
|
||||||
|
"folderSelection.servers.subtitle": "Ouvrez un serveur CodeNomad distant enregistré dans une nouvelle fenêtre",
|
||||||
|
"folderSelection.servers.count": "{count} serveurs",
|
||||||
|
"folderSelection.servers.empty.title": "Aucun serveur enregistré",
|
||||||
|
"folderSelection.servers.empty.description": "Ajoutez un serveur distant pour vous reconnecter rapidement depuis cet appareil",
|
||||||
|
"folderSelection.servers.connectTitle": "Se connecter à un serveur",
|
||||||
|
"folderSelection.servers.connectSubtitle": "Enregistrez un serveur CodeNomad distant et ouvrez-le dans une nouvelle fenêtre",
|
||||||
|
"folderSelection.servers.connectButton": "Se connecter à un serveur",
|
||||||
|
"folderSelection.servers.remove": "Supprimer le serveur enregistré",
|
||||||
|
"folderSelection.servers.skipTls": "TLS auto-signé",
|
||||||
|
"folderSelection.servers.errorTitle": "Échec de la connexion distante",
|
||||||
|
"folderSelection.servers.dialog.title": "Se connecter à un serveur",
|
||||||
|
"folderSelection.servers.dialog.description": "Ajoutez un serveur CodeNomad distant et ouvrez-le immédiatement si vous le souhaitez.",
|
||||||
|
"folderSelection.servers.dialog.name": "Nom du serveur",
|
||||||
|
"folderSelection.servers.dialog.namePlaceholder": "Serveur de production",
|
||||||
|
"folderSelection.servers.dialog.url": "URL du serveur",
|
||||||
|
"folderSelection.servers.dialog.urlPlaceholder": "https://server.example.com",
|
||||||
|
"folderSelection.servers.dialog.skipTls": "Ignorer la vérification TLS pour les certificats auto-signés.",
|
||||||
|
"folderSelection.servers.dialog.cancel": "Annuler",
|
||||||
|
"folderSelection.servers.dialog.save": "Enregistrer",
|
||||||
|
"folderSelection.servers.dialog.connect": "Se connecter",
|
||||||
|
"folderSelection.servers.dialog.connecting": "Connexion...",
|
||||||
|
"folderSelection.servers.dialog.errorRequired": "Le nom du serveur et l'URL sont requis.",
|
||||||
|
"folderSelection.servers.dialog.errorConnect": "Impossible de se connecter au serveur distant.",
|
||||||
|
"folderSelection.sidecars.button": "Open SideCar",
|
||||||
} as const
|
} as const
|
||||||
|
|||||||
@@ -26,7 +26,6 @@ export const instanceMessages = {
|
|||||||
|
|
||||||
"instanceShell.leftPanel.sessionsTitle": "Sessions",
|
"instanceShell.leftPanel.sessionsTitle": "Sessions",
|
||||||
"instanceShell.leftPanel.instanceInfo": "Infos de l'instance",
|
"instanceShell.leftPanel.instanceInfo": "Infos de l'instance",
|
||||||
|
|
||||||
"instanceShell.leftDrawer.pin": "Épingler le tiroir gauche",
|
"instanceShell.leftDrawer.pin": "Épingler le tiroir gauche",
|
||||||
"instanceShell.leftDrawer.unpin": "Désépingler le tiroir gauche",
|
"instanceShell.leftDrawer.unpin": "Désépingler le tiroir gauche",
|
||||||
"instanceShell.leftDrawer.toggle.pinned": "Tiroir gauche épinglé",
|
"instanceShell.leftDrawer.toggle.pinned": "Tiroir gauche épinglé",
|
||||||
@@ -107,6 +106,8 @@ export const instanceMessages = {
|
|||||||
"instanceShell.rightPanel.actions.refreshDirty.cancelLabel": "Annuler",
|
"instanceShell.rightPanel.actions.refreshDirty.cancelLabel": "Annuler",
|
||||||
"instanceShell.rightPanel.toast.saveSuccess": "Fichier enregistré avec succès",
|
"instanceShell.rightPanel.toast.saveSuccess": "Fichier enregistré avec succès",
|
||||||
"instanceShell.rightPanel.toast.saveError": "Échec de l'enregistrement du fichier",
|
"instanceShell.rightPanel.toast.saveError": "Échec de l'enregistrement du fichier",
|
||||||
|
"instanceShell.rightPanel.sections.yoloMode": "Mode yolo",
|
||||||
|
"instanceShell.rightPanel.sections.yoloMode.tooltip": "Approuve automatiquement les demandes d'autorisation pour la session actuelle. A utiliser seulement si vous faites confiance aux outils executes.",
|
||||||
"instanceShell.rightPanel.sections.sessionChanges": "Changements de session",
|
"instanceShell.rightPanel.sections.sessionChanges": "Changements de session",
|
||||||
"instanceShell.rightPanel.sections.sessionChanges.tooltip": "Fichiers modifiés dans la session actuelle. Affiche les ajouts et suppressions pour chaque fichier.",
|
"instanceShell.rightPanel.sections.sessionChanges.tooltip": "Fichiers modifiés dans la session actuelle. Affiche les ajouts et suppressions pour chaque fichier.",
|
||||||
"instanceShell.rightPanel.sections.plan": "Plan",
|
"instanceShell.rightPanel.sections.plan": "Plan",
|
||||||
@@ -140,6 +141,12 @@ export const instanceMessages = {
|
|||||||
"instanceShell.plan.noSessionSelected": "Sélectionnez une session pour voir le plan.",
|
"instanceShell.plan.noSessionSelected": "Sélectionnez une session pour voir le plan.",
|
||||||
"instanceShell.plan.empty": "Aucun plan pour l'instant.",
|
"instanceShell.plan.empty": "Aucun plan pour l'instant.",
|
||||||
|
|
||||||
|
"instanceShell.yoloMode.noSessionSelected": "Selectionnez une session pour configurer le mode yolo.",
|
||||||
|
"instanceShell.yoloMode.title": "Mode yolo",
|
||||||
|
"instanceShell.yoloMode.description": "Approuve automatiquement les demandes d'autorisation pour cette session. Desactive par defaut.",
|
||||||
|
"instanceShell.yoloMode.badge": "Mode yolo",
|
||||||
|
"instanceShell.yoloMode.badgeAriaLabel": "Mode yolo active",
|
||||||
|
|
||||||
"instanceShell.backgroundProcesses.empty": "Aucun processus en arrière-plan.",
|
"instanceShell.backgroundProcesses.empty": "Aucun processus en arrière-plan.",
|
||||||
"instanceShell.backgroundProcesses.status": "Statut : {status}",
|
"instanceShell.backgroundProcesses.status": "Statut : {status}",
|
||||||
"instanceShell.backgroundProcesses.output": "Sortie : {sizeKb}KB",
|
"instanceShell.backgroundProcesses.output": "Sortie : {sizeKb}KB",
|
||||||
|
|||||||
@@ -41,6 +41,8 @@ export const remoteAccessMessages = {
|
|||||||
"remoteAccess.sections.addresses.help": "Lancez ou scannez depuis une autre machine pour passer le contrôle.",
|
"remoteAccess.sections.addresses.help": "Lancez ou scannez depuis une autre machine pour passer le contrôle.",
|
||||||
"remoteAccess.addresses.loading": "Chargement des adresses…",
|
"remoteAccess.addresses.loading": "Chargement des adresses…",
|
||||||
"remoteAccess.addresses.none": "Aucune adresse disponible pour le moment.",
|
"remoteAccess.addresses.none": "Aucune adresse disponible pour le moment.",
|
||||||
|
"remoteAccess.addresses.actions.showOther": "Afficher {count} autres adresses",
|
||||||
|
"remoteAccess.addresses.actions.hideOther": "Masquer les autres adresses",
|
||||||
"remoteAccess.address.scope.network": "Réseau",
|
"remoteAccess.address.scope.network": "Réseau",
|
||||||
"remoteAccess.address.scope.loopback": "Boucle locale",
|
"remoteAccess.address.scope.loopback": "Boucle locale",
|
||||||
"remoteAccess.address.scope.internal": "Interne",
|
"remoteAccess.address.scope.internal": "Interne",
|
||||||
|
|||||||
@@ -15,6 +15,10 @@ export const sessionMessages = {
|
|||||||
"sessionList.status.working": "En cours",
|
"sessionList.status.working": "En cours",
|
||||||
"sessionList.status.compacting": "Compactage",
|
"sessionList.status.compacting": "Compactage",
|
||||||
"sessionList.status.idle": "Inactif",
|
"sessionList.status.idle": "Inactif",
|
||||||
|
"sessionList.status.retrying": "Nouvelle tentative",
|
||||||
|
"sessionList.status.retryingIn": "Nouvelle tentative dans {seconds}s",
|
||||||
|
"sessionList.status.retryTooltip": "{message} (Tentative {attempt})",
|
||||||
|
"sessionList.status.retryToast": "{countdown} : {message} (Tentative {attempt})",
|
||||||
"sessionList.status.needsPermission": "Autorisation requise",
|
"sessionList.status.needsPermission": "Autorisation requise",
|
||||||
"sessionList.status.needsInput": "Entrée requise",
|
"sessionList.status.needsInput": "Entrée requise",
|
||||||
"sessionList.expand.collapseAriaLabel": "Réduire la session",
|
"sessionList.expand.collapseAriaLabel": "Réduire la session",
|
||||||
@@ -25,12 +29,15 @@ export const sessionMessages = {
|
|||||||
"sessionList.actions.newSession.title": "Nouvelle session",
|
"sessionList.actions.newSession.title": "Nouvelle session",
|
||||||
"sessionList.actions.copyId.ariaLabel": "Copier l'ID de session",
|
"sessionList.actions.copyId.ariaLabel": "Copier l'ID de session",
|
||||||
"sessionList.actions.copyId.title": "Copier l'ID de session",
|
"sessionList.actions.copyId.title": "Copier l'ID de session",
|
||||||
|
"sessionList.actions.reload.ariaLabel": "Recharger la session",
|
||||||
|
"sessionList.actions.reload.title": "Recharger la session",
|
||||||
"sessionList.actions.rename.ariaLabel": "Renommer la session",
|
"sessionList.actions.rename.ariaLabel": "Renommer la session",
|
||||||
"sessionList.actions.rename.title": "Renommer la session",
|
"sessionList.actions.rename.title": "Renommer la session",
|
||||||
"sessionList.actions.delete.ariaLabel": "Supprimer la session",
|
"sessionList.actions.delete.ariaLabel": "Supprimer la session",
|
||||||
"sessionList.actions.delete.title": "Supprimer la session",
|
"sessionList.actions.delete.title": "Supprimer la session",
|
||||||
"sessionList.copyId.success": "ID de session copié",
|
"sessionList.copyId.success": "ID de session copié",
|
||||||
"sessionList.copyId.error": "Impossible de copier l'ID de session",
|
"sessionList.copyId.error": "Impossible de copier l'ID de session",
|
||||||
|
"sessionList.reload.error": "Impossible de recharger la session",
|
||||||
"sessionList.delete.error": "Impossible de supprimer la session",
|
"sessionList.delete.error": "Impossible de supprimer la session",
|
||||||
"sessionList.delete.title": "Supprimer la session",
|
"sessionList.delete.title": "Supprimer la session",
|
||||||
"sessionList.delete.confirmMessage": "Supprimer \"{label}\" ? Cette action est irréversible.",
|
"sessionList.delete.confirmMessage": "Supprimer \"{label}\" ? Cette action est irréversible.",
|
||||||
|
|||||||
@@ -113,6 +113,14 @@ export const settingsMessages = {
|
|||||||
"settings.section.opencode.subtitle": "Choose the OpenCode binary and environment used for new instances.",
|
"settings.section.opencode.subtitle": "Choose the OpenCode binary and environment used for new instances.",
|
||||||
"settings.opencode.runtime.title": "Runtime",
|
"settings.opencode.runtime.title": "Runtime",
|
||||||
"settings.opencode.runtime.subtitle": "Configure which OpenCode binary new instances launch with.",
|
"settings.opencode.runtime.subtitle": "Configure which OpenCode binary new instances launch with.",
|
||||||
|
"settings.opencode.logLevel.title": "Niveau de logs OpenCode",
|
||||||
|
"settings.opencode.logLevel.subtitle": "Definir le niveau de logs utilise au lancement des nouvelles instances OpenCode.",
|
||||||
|
"settings.opencode.logLevel.selector.title": "Verbosite des logs",
|
||||||
|
"settings.opencode.logLevel.selector.subtitle": "Choisir la quantite de journaux emise par les nouvelles instances OpenCode.",
|
||||||
|
"settings.opencode.logLevel.option.debug": "Debogage",
|
||||||
|
"settings.opencode.logLevel.option.info": "Info",
|
||||||
|
"settings.opencode.logLevel.option.warn": "Avertissement",
|
||||||
|
"settings.opencode.logLevel.option.error": "Erreur",
|
||||||
|
|
||||||
"settings.appearance.behavior.title": "Interaction",
|
"settings.appearance.behavior.title": "Interaction",
|
||||||
"settings.appearance.behavior.subtitle": "Parametres par defaut pour les messages, les diffs et la saisie.",
|
"settings.appearance.behavior.subtitle": "Parametres par defaut pour les messages, les diffs et la saisie.",
|
||||||
@@ -186,4 +194,40 @@ export const settingsMessages = {
|
|||||||
"settings.speech.save.saved": "Enregistré",
|
"settings.speech.save.saved": "Enregistré",
|
||||||
"settings.speech.save.unsaved": "Modifications non enregistrées",
|
"settings.speech.save.unsaved": "Modifications non enregistrées",
|
||||||
"settings.speech.save.error": "Échec de l'enregistrement",
|
"settings.speech.save.error": "Échec de l'enregistrement",
|
||||||
|
"settings.nav.sidecars": "SideCars",
|
||||||
|
"settings.section.sidecars.eyebrow": "Server services",
|
||||||
|
"settings.section.sidecars.title": "SideCars",
|
||||||
|
"settings.section.sidecars.subtitle": "Configure local services listening on fixed ports that the server can proxy into tabs.",
|
||||||
|
"sidecars.form.name": "Name",
|
||||||
|
"sidecars.form.validation": "Enter a valid SideCar name and port.",
|
||||||
|
"sidecars.form.port": "Port",
|
||||||
|
"sidecars.form.insecure": "Use HTTP",
|
||||||
|
"sidecars.form.protocol": "Protocol",
|
||||||
|
"sidecars.form.protocol.help": "Choose how the proxy should connect to the local service.",
|
||||||
|
"sidecars.form.protocol.https": "HTTPS",
|
||||||
|
"sidecars.form.protocol.http": "HTTP",
|
||||||
|
"sidecars.form.prefixMode": "Prefix mode",
|
||||||
|
"sidecars.form.prefixMode.help": "Choose whether the SideCar receives the public /sidecars path prefix.",
|
||||||
|
"sidecars.form.prefixMode.strip": "Strip prefix",
|
||||||
|
"sidecars.form.prefixMode.preserve": "Preserve prefix",
|
||||||
|
"sidecars.form.add": "Add SideCar",
|
||||||
|
"sidecars.kind.port": "Port",
|
||||||
|
"sidecars.status.running": "Running",
|
||||||
|
"sidecars.status.stopped": "Stopped",
|
||||||
|
"sidecars.basePath": "Base path",
|
||||||
|
"sidecars.settings.listTitle": "Configured SideCars",
|
||||||
|
"sidecars.settings.listSubtitle": "Review the port-based SideCars available in the picker.",
|
||||||
|
"sidecars.settings.empty": "No SideCars configured yet.",
|
||||||
|
"sidecars.picker.title": "Open SideCar",
|
||||||
|
"sidecars.picker.loading": "Loading SideCars...",
|
||||||
|
"sidecars.picker.subtitle": "Choose an available SideCar to open in a new tab.",
|
||||||
|
"sidecars.picker.empty": "No port-based SideCars are available yet.",
|
||||||
|
"sidecars.picker.close": "Close",
|
||||||
|
"sidecars.open.errorTitle": "Unable to open SideCar",
|
||||||
|
"sidecars.open.notFound": "SideCar not found.",
|
||||||
|
"sidecars.open.notRunning": "SideCar is not reachable on its configured port.",
|
||||||
|
"sidecars.back": "Back",
|
||||||
|
"sidecars.refresh": "Refresh",
|
||||||
|
"sidecars.path": "Path",
|
||||||
|
"sidecars.go": "Go",
|
||||||
} as const
|
} as const
|
||||||
|
|||||||
@@ -18,6 +18,11 @@ export const toolCallMessages = {
|
|||||||
"toolCall.diff.viewMode.ariaLabel": "Mode d'affichage du diff",
|
"toolCall.diff.viewMode.ariaLabel": "Mode d'affichage du diff",
|
||||||
"toolCall.diff.viewMode.split": "Côte à côte",
|
"toolCall.diff.viewMode.split": "Côte à côte",
|
||||||
"toolCall.diff.viewMode.unified": "Unifié",
|
"toolCall.diff.viewMode.unified": "Unifié",
|
||||||
|
"toolCall.diff.switchToSplit": "Passer à la vue côte à côte",
|
||||||
|
"toolCall.diff.switchToUnified": "Passer à la vue unifiée",
|
||||||
|
"toolCall.diff.enableWordWrap": "Activer le retour à la ligne",
|
||||||
|
"toolCall.diff.disableWordWrap": "Désactiver le retour à la ligne",
|
||||||
|
"toolCall.diff.copyPatch": "Copier le patch",
|
||||||
|
|
||||||
"toolCall.diagnostics.title": "Diagnostics",
|
"toolCall.diagnostics.title": "Diagnostics",
|
||||||
"toolCall.diagnostics.ariaLabel": "Diagnostics",
|
"toolCall.diagnostics.ariaLabel": "Diagnostics",
|
||||||
|
|||||||
@@ -15,17 +15,17 @@ export const commandMessages = {
|
|||||||
"commands.newInstance.description": "פתח בורר תיקיות ליצירת מופע חדש",
|
"commands.newInstance.description": "פתח בורר תיקיות ליצירת מופע חדש",
|
||||||
"commands.newInstance.keywords": "תיקייה, פרויקט, סביבת עבודה",
|
"commands.newInstance.keywords": "תיקייה, פרויקט, סביבת עבודה",
|
||||||
|
|
||||||
"commands.closeInstance.label": "סגור מופע",
|
"commands.closeInstance.label": "סגור לשונית",
|
||||||
"commands.closeInstance.description": "עצור את השרת של המופע הנוכחי",
|
"commands.closeInstance.description": "סגור את הלשונית העליונה הנוכחית",
|
||||||
"commands.closeInstance.keywords": "עצור, סגור",
|
"commands.closeInstance.keywords": "עצור, סגור, לשונית",
|
||||||
|
|
||||||
"commands.nextInstance.label": "מופע הבא",
|
"commands.nextInstance.label": "הלשונית הבאה",
|
||||||
"commands.nextInstance.description": "עבור למופע הבא",
|
"commands.nextInstance.description": "עבור ללשונית העליונה הבאה",
|
||||||
"commands.nextInstance.keywords": "החלף, נווט",
|
"commands.nextInstance.keywords": "החלף, נווט, לשונית",
|
||||||
|
|
||||||
"commands.previousInstance.label": "מופע קודם",
|
"commands.previousInstance.label": "הלשונית הקודמת",
|
||||||
"commands.previousInstance.description": "עבור למופע הקודם",
|
"commands.previousInstance.description": "עבור ללשונית העליונה הקודמת",
|
||||||
"commands.previousInstance.keywords": "החלף, נווט",
|
"commands.previousInstance.keywords": "החלף, נווט, לשונית",
|
||||||
|
|
||||||
"commands.newSession.label": "סשן חדש",
|
"commands.newSession.label": "סשן חדש",
|
||||||
"commands.newSession.description": "צור סשן הורה חדש",
|
"commands.newSession.description": "צור סשן הורה חדש",
|
||||||
|
|||||||
@@ -20,6 +20,9 @@ export const folderSelectionMessages = {
|
|||||||
"folderSelection.browse.subtitle": "בחר כל תיקייה במחשב שלך",
|
"folderSelection.browse.subtitle": "בחר כל תיקייה במחשב שלך",
|
||||||
"folderSelection.browse.button": "עיון בתיקיות",
|
"folderSelection.browse.button": "עיון בתיקיות",
|
||||||
"folderSelection.browse.buttonOpening": "פותח...",
|
"folderSelection.browse.buttonOpening": "פותח...",
|
||||||
|
"folderSelection.actions.title": "פתח תיקייה או התחבר לשרת",
|
||||||
|
"folderSelection.actions.subtitle": "פתח תיקייה מקומית או התחבר לשרת CodeNomad",
|
||||||
|
"folderSelection.actions.connectButton": "התחבר לשרת CodeNomad",
|
||||||
|
|
||||||
"folderSelection.advancedSettings": "הגדרות מתקדמות",
|
"folderSelection.advancedSettings": "הגדרות מתקדמות",
|
||||||
"folderSelection.opencode": "OpenCode",
|
"folderSelection.opencode": "OpenCode",
|
||||||
@@ -39,4 +42,32 @@ export const folderSelectionMessages = {
|
|||||||
|
|
||||||
"folderSelection.dialog.title": "בחר סביבת עבודה",
|
"folderSelection.dialog.title": "בחר סביבת עבודה",
|
||||||
"folderSelection.dialog.description": "בחר סביבת עבודה כדי להתחיל לתכנת.",
|
"folderSelection.dialog.description": "בחר סביבת עבודה כדי להתחיל לתכנת.",
|
||||||
|
|
||||||
|
"folderSelection.tabs.local": "תיקיות מקומיות",
|
||||||
|
"folderSelection.tabs.servers": "שרתים",
|
||||||
|
"folderSelection.servers.title": "שרתים שמורים",
|
||||||
|
"folderSelection.servers.subtitle": "פתח שרת CodeNomad מרוחק שמור בחלון חדש",
|
||||||
|
"folderSelection.servers.count": "{count} שרתים",
|
||||||
|
"folderSelection.servers.empty.title": "אין שרתים שמורים",
|
||||||
|
"folderSelection.servers.empty.description": "הוסף שרת מרוחק כדי להתחבר אליו במהירות מהמכשיר הזה",
|
||||||
|
"folderSelection.servers.connectTitle": "התחבר לשרת",
|
||||||
|
"folderSelection.servers.connectSubtitle": "שמור שרת CodeNomad מרוחק ופתח אותו בחלון חדש",
|
||||||
|
"folderSelection.servers.connectButton": "התחבר לשרת",
|
||||||
|
"folderSelection.servers.remove": "הסר שרת שמור",
|
||||||
|
"folderSelection.servers.skipTls": "TLS בחתימה עצמית",
|
||||||
|
"folderSelection.servers.errorTitle": "החיבור המרוחק נכשל",
|
||||||
|
"folderSelection.servers.dialog.title": "התחבר לשרת",
|
||||||
|
"folderSelection.servers.dialog.description": "הוסף שרת CodeNomad מרוחק ופתח אותו מיד אם תרצה.",
|
||||||
|
"folderSelection.servers.dialog.name": "שם השרת",
|
||||||
|
"folderSelection.servers.dialog.namePlaceholder": "שרת ייצור",
|
||||||
|
"folderSelection.servers.dialog.url": "כתובת השרת",
|
||||||
|
"folderSelection.servers.dialog.urlPlaceholder": "https://server.example.com",
|
||||||
|
"folderSelection.servers.dialog.skipTls": "דלג על אימות TLS עבור תעודות בחתימה עצמית.",
|
||||||
|
"folderSelection.servers.dialog.cancel": "ביטול",
|
||||||
|
"folderSelection.servers.dialog.save": "שמור",
|
||||||
|
"folderSelection.servers.dialog.connect": "התחבר",
|
||||||
|
"folderSelection.servers.dialog.connecting": "מתחבר...",
|
||||||
|
"folderSelection.servers.dialog.errorRequired": "שם השרת והכתובת הם שדות חובה.",
|
||||||
|
"folderSelection.servers.dialog.errorConnect": "לא ניתן היה להתחבר לשרת המרוחק.",
|
||||||
|
"folderSelection.sidecars.button": "Open SideCar",
|
||||||
} as const
|
} as const
|
||||||
|
|||||||
@@ -26,7 +26,6 @@ export const instanceMessages = {
|
|||||||
|
|
||||||
"instanceShell.leftPanel.sessionsTitle": "סשנים",
|
"instanceShell.leftPanel.sessionsTitle": "סשנים",
|
||||||
"instanceShell.leftPanel.instanceInfo": "מידע על המופע",
|
"instanceShell.leftPanel.instanceInfo": "מידע על המופע",
|
||||||
|
|
||||||
"instanceShell.leftDrawer.pin": "נעץ מגירה שמאלית",
|
"instanceShell.leftDrawer.pin": "נעץ מגירה שמאלית",
|
||||||
"instanceShell.leftDrawer.unpin": "שחרר נעיצת מגירה שמאלית",
|
"instanceShell.leftDrawer.unpin": "שחרר נעיצת מגירה שמאלית",
|
||||||
"instanceShell.leftDrawer.toggle.pinned": "המגירה השמאלית נעוצה",
|
"instanceShell.leftDrawer.toggle.pinned": "המגירה השמאלית נעוצה",
|
||||||
@@ -107,6 +106,8 @@ export const instanceMessages = {
|
|||||||
"instanceShell.rightPanel.actions.refreshDirty.cancelLabel": "בטל",
|
"instanceShell.rightPanel.actions.refreshDirty.cancelLabel": "בטל",
|
||||||
"instanceShell.rightPanel.toast.saveSuccess": "הקובץ נשמר בהצלחה",
|
"instanceShell.rightPanel.toast.saveSuccess": "הקובץ נשמר בהצלחה",
|
||||||
"instanceShell.rightPanel.toast.saveError": "כשלון בשמירת הקובץ",
|
"instanceShell.rightPanel.toast.saveError": "כשלון בשמירת הקובץ",
|
||||||
|
"instanceShell.rightPanel.sections.yoloMode": "מצב Yolo",
|
||||||
|
"instanceShell.rightPanel.sections.yoloMode.tooltip": "מאשר אוטומטית בקשות הרשאה עבור הסשן הנוכחי. השתמשו בזה רק אם אתם סומכים על הכלים שרצים.",
|
||||||
"instanceShell.rightPanel.sections.sessionChanges": "שינויי סשן",
|
"instanceShell.rightPanel.sections.sessionChanges": "שינויי סשן",
|
||||||
"instanceShell.rightPanel.sections.sessionChanges.tooltip": "קבצים שהשתנו בסשן הנוכחי. מציג הוספות ומחיקות לכל קובץ.",
|
"instanceShell.rightPanel.sections.sessionChanges.tooltip": "קבצים שהשתנו בסשן הנוכחי. מציג הוספות ומחיקות לכל קובץ.",
|
||||||
"instanceShell.rightPanel.sections.plan": "תוכנית",
|
"instanceShell.rightPanel.sections.plan": "תוכנית",
|
||||||
@@ -148,6 +149,12 @@ export const instanceMessages = {
|
|||||||
"instanceShell.plan.noSessionSelected": "בחר סשן לצפייה בתוכנית.",
|
"instanceShell.plan.noSessionSelected": "בחר סשן לצפייה בתוכנית.",
|
||||||
"instanceShell.plan.empty": "עדיין לא תוכנן דבר.",
|
"instanceShell.plan.empty": "עדיין לא תוכנן דבר.",
|
||||||
|
|
||||||
|
"instanceShell.yoloMode.noSessionSelected": "בחרו סשן כדי להגדיר מצב Yolo.",
|
||||||
|
"instanceShell.yoloMode.title": "מצב Yolo",
|
||||||
|
"instanceShell.yoloMode.description": "מאשר אוטומטית בקשות הרשאה עבור הסשן הזה. כבוי כברירת מחדל.",
|
||||||
|
"instanceShell.yoloMode.badge": "Yolo",
|
||||||
|
"instanceShell.yoloMode.badgeAriaLabel": "מצב Yolo פעיל",
|
||||||
|
|
||||||
"instanceShell.backgroundProcesses.empty": "אין תהליכי רקע.",
|
"instanceShell.backgroundProcesses.empty": "אין תהליכי רקע.",
|
||||||
"instanceShell.backgroundProcesses.status": "סטטוס: {status}",
|
"instanceShell.backgroundProcesses.status": "סטטוס: {status}",
|
||||||
"instanceShell.backgroundProcesses.output": "פלט: {sizeKb}KB",
|
"instanceShell.backgroundProcesses.output": "פלט: {sizeKb}KB",
|
||||||
|
|||||||
@@ -41,6 +41,8 @@ export const remoteAccessMessages = {
|
|||||||
"remoteAccess.sections.addresses.help": "הפעל או סרוק ממכונה אחרת להעברת שליטה.",
|
"remoteAccess.sections.addresses.help": "הפעל או סרוק ממכונה אחרת להעברת שליטה.",
|
||||||
"remoteAccess.addresses.loading": "טוען כתובות…",
|
"remoteAccess.addresses.loading": "טוען כתובות…",
|
||||||
"remoteAccess.addresses.none": "אין כתובות זמינות עדיין.",
|
"remoteAccess.addresses.none": "אין כתובות זמינות עדיין.",
|
||||||
|
"remoteAccess.addresses.actions.showOther": "הצג עוד {count} כתובות",
|
||||||
|
"remoteAccess.addresses.actions.hideOther": "הסתר כתובות נוספות",
|
||||||
"remoteAccess.address.scope.network": "רשת",
|
"remoteAccess.address.scope.network": "רשת",
|
||||||
"remoteAccess.address.scope.loopback": "לולאה מקומית",
|
"remoteAccess.address.scope.loopback": "לולאה מקומית",
|
||||||
"remoteAccess.address.scope.internal": "פנימי",
|
"remoteAccess.address.scope.internal": "פנימי",
|
||||||
|
|||||||
@@ -15,6 +15,10 @@ export const sessionMessages = {
|
|||||||
"sessionList.status.working": "עובד",
|
"sessionList.status.working": "עובד",
|
||||||
"sessionList.status.compacting": "מסכם",
|
"sessionList.status.compacting": "מסכם",
|
||||||
"sessionList.status.idle": "מוכן",
|
"sessionList.status.idle": "מוכן",
|
||||||
|
"sessionList.status.retrying": "מנסה שוב",
|
||||||
|
"sessionList.status.retryingIn": "מנסה שוב בעוד {seconds}ש׳",
|
||||||
|
"sessionList.status.retryTooltip": "{message} (ניסיון {attempt})",
|
||||||
|
"sessionList.status.retryToast": "{countdown}: {message} (ניסיון {attempt})",
|
||||||
"sessionList.status.needsPermission": "נדרש אישור",
|
"sessionList.status.needsPermission": "נדרש אישור",
|
||||||
"sessionList.status.needsInput": "נדרש קלט",
|
"sessionList.status.needsInput": "נדרש קלט",
|
||||||
"sessionList.expand.collapseAriaLabel": "כווץ סשן",
|
"sessionList.expand.collapseAriaLabel": "כווץ סשן",
|
||||||
@@ -25,12 +29,15 @@ export const sessionMessages = {
|
|||||||
"sessionList.actions.newSession.title": "סשן חדש",
|
"sessionList.actions.newSession.title": "סשן חדש",
|
||||||
"sessionList.actions.copyId.ariaLabel": "העתק מזהה סשן",
|
"sessionList.actions.copyId.ariaLabel": "העתק מזהה סשן",
|
||||||
"sessionList.actions.copyId.title": "העתק מזהה סשן",
|
"sessionList.actions.copyId.title": "העתק מזהה סשן",
|
||||||
|
"sessionList.actions.reload.ariaLabel": "טען מחדש סשן",
|
||||||
|
"sessionList.actions.reload.title": "טען מחדש סשן",
|
||||||
"sessionList.actions.rename.ariaLabel": "שנה שם סשן",
|
"sessionList.actions.rename.ariaLabel": "שנה שם סשן",
|
||||||
"sessionList.actions.rename.title": "שנה שם סשן",
|
"sessionList.actions.rename.title": "שנה שם סשן",
|
||||||
"sessionList.actions.delete.ariaLabel": "מחק סשן",
|
"sessionList.actions.delete.ariaLabel": "מחק סשן",
|
||||||
"sessionList.actions.delete.title": "מחק סשן",
|
"sessionList.actions.delete.title": "מחק סשן",
|
||||||
"sessionList.copyId.success": "מזהה סשן הועתק",
|
"sessionList.copyId.success": "מזהה סשן הועתק",
|
||||||
"sessionList.copyId.error": "לא ניתן להעתיק מזהה סשן",
|
"sessionList.copyId.error": "לא ניתן להעתיק מזהה סשן",
|
||||||
|
"sessionList.reload.error": "לא ניתן לטעון מחדש את הסשן",
|
||||||
"sessionList.delete.error": "לא ניתן למחוק סשן",
|
"sessionList.delete.error": "לא ניתן למחוק סשן",
|
||||||
"sessionList.delete.title": "מחק סשן",
|
"sessionList.delete.title": "מחק סשן",
|
||||||
"sessionList.delete.confirmMessage": "למחוק את \"{label}\"? לא ניתן לבטל פעולה זו.",
|
"sessionList.delete.confirmMessage": "למחוק את \"{label}\"? לא ניתן לבטל פעולה זו.",
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user