Compare commits
39 Commits
v0.13.3
...
v0.13.3-de
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e82e529a8f | ||
|
|
4f236ce36f | ||
|
|
2ffeb45a9c | ||
|
|
df16b64a95 | ||
|
|
f3c54df283 | ||
|
|
5658a9f62d | ||
|
|
9d6a5bcdc0 | ||
|
|
514b187b00 | ||
|
|
240acb7729 | ||
|
|
278b563c1a | ||
|
|
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 |
@@ -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)
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -328,7 +328,6 @@ function finalizeCliSwap(url: string) {
|
|||||||
mainWindow.loadURL(url).catch((error) => console.error("[cli] failed to load CLI view:", error))
|
mainWindow.loadURL(url).catch((error) => console.error("[cli] failed to load CLI view:", error))
|
||||||
}
|
}
|
||||||
|
|
||||||
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 +350,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 +381,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: "/",
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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) {
|
||||||
@@ -139,6 +141,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")
|
||||||
|
|||||||
@@ -19,9 +19,9 @@ 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"
|
||||||
|
|
||||||
@@ -55,6 +55,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 +101,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 +145,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 +192,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 +274,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,
|
||||||
},
|
},
|
||||||
@@ -442,18 +451,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 +477,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 +487,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) {
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,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,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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");
|
||||||
@@ -584,6 +604,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
|
||||||
@@ -605,6 +626,7 @@ impl CliProcessManager {
|
|||||||
&status_clone,
|
&status_clone,
|
||||||
&ready_clone,
|
&ready_clone,
|
||||||
&token_clone,
|
&token_clone,
|
||||||
|
auth_cookie_name_clone.as_str(),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if let Some(reader) = stderr {
|
if let Some(reader) = stderr {
|
||||||
@@ -615,6 +637,7 @@ impl CliProcessManager {
|
|||||||
&status_clone,
|
&status_clone,
|
||||||
&ready_clone,
|
&ready_clone,
|
||||||
&token_clone,
|
&token_clone,
|
||||||
|
auth_cookie_name_clone.as_str(),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -731,6 +754,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,
|
||||||
) {
|
) {
|
||||||
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+)").ok();
|
||||||
@@ -766,7 +790,14 @@ 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;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -781,6 +812,7 @@ impl CliProcessManager {
|
|||||||
status,
|
status,
|
||||||
ready,
|
ready,
|
||||||
bootstrap_token,
|
bootstrap_token,
|
||||||
|
auth_cookie_name,
|
||||||
format!("http://localhost:{port}"),
|
format!("http://localhost:{port}"),
|
||||||
);
|
);
|
||||||
continue;
|
continue;
|
||||||
@@ -793,6 +825,7 @@ impl CliProcessManager {
|
|||||||
status,
|
status,
|
||||||
ready,
|
ready,
|
||||||
bootstrap_token,
|
bootstrap_token,
|
||||||
|
auth_cookie_name,
|
||||||
format!("http://localhost:{}", port),
|
format!("http://localhost:{}", port),
|
||||||
);
|
);
|
||||||
continue;
|
continue;
|
||||||
@@ -811,6 +844,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 +868,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 +968,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(),
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@@ -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?.()
|
||||||
@@ -86,6 +262,7 @@ export function ToolCallDiffViewer(props: ToolCallDiffViewerProps) {
|
|||||||
|
|
||||||
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>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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,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>
|
||||||
|
|||||||
@@ -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}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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": "פנימי",
|
||||||
|
|||||||
@@ -18,6 +18,11 @@ export const toolCallMessages = {
|
|||||||
"toolCall.diff.viewMode.ariaLabel": "מצב תצוגת diff",
|
"toolCall.diff.viewMode.ariaLabel": "מצב תצוגת diff",
|
||||||
"toolCall.diff.viewMode.split": "מפוצל",
|
"toolCall.diff.viewMode.split": "מפוצל",
|
||||||
"toolCall.diff.viewMode.unified": "מאוחד",
|
"toolCall.diff.viewMode.unified": "מאוחד",
|
||||||
|
"toolCall.diff.switchToSplit": "עבור לתצוגה מפוצלת",
|
||||||
|
"toolCall.diff.switchToUnified": "עבור לתצוגה מאוחדת",
|
||||||
|
"toolCall.diff.enableWordWrap": "הפעל גלישת מילים",
|
||||||
|
"toolCall.diff.disableWordWrap": "כבה גלישת מילים",
|
||||||
|
"toolCall.diff.copyPatch": "העתק patch",
|
||||||
|
|
||||||
"toolCall.diagnostics.title": "אבחון",
|
"toolCall.diagnostics.title": "אבחון",
|
||||||
"toolCall.diagnostics.ariaLabel": "אבחון",
|
"toolCall.diagnostics.ariaLabel": "אבחון",
|
||||||
|
|||||||
@@ -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": "内部",
|
||||||
|
|||||||
@@ -18,6 +18,11 @@ export const toolCallMessages = {
|
|||||||
"toolCall.diff.viewMode.ariaLabel": "diff 表示モード",
|
"toolCall.diff.viewMode.ariaLabel": "diff 表示モード",
|
||||||
"toolCall.diff.viewMode.split": "分割",
|
"toolCall.diff.viewMode.split": "分割",
|
||||||
"toolCall.diff.viewMode.unified": "ユニファイド",
|
"toolCall.diff.viewMode.unified": "ユニファイド",
|
||||||
|
"toolCall.diff.switchToSplit": "分割表示に切り替え",
|
||||||
|
"toolCall.diff.switchToUnified": "ユニファイド表示に切り替え",
|
||||||
|
"toolCall.diff.enableWordWrap": "折り返しを有効化",
|
||||||
|
"toolCall.diff.disableWordWrap": "折り返しを無効化",
|
||||||
|
"toolCall.diff.copyPatch": "パッチをコピー",
|
||||||
|
|
||||||
"toolCall.diagnostics.title": "診断",
|
"toolCall.diagnostics.title": "診断",
|
||||||
"toolCall.diagnostics.ariaLabel": "診断",
|
"toolCall.diagnostics.ariaLabel": "診断",
|
||||||
|
|||||||
@@ -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": "Loopback",
|
"remoteAccess.address.scope.loopback": "Loopback",
|
||||||
"remoteAccess.address.scope.internal": "Внутренний",
|
"remoteAccess.address.scope.internal": "Внутренний",
|
||||||
|
|||||||
@@ -18,6 +18,11 @@ export const toolCallMessages = {
|
|||||||
"toolCall.diff.viewMode.ariaLabel": "Режим просмотра diff",
|
"toolCall.diff.viewMode.ariaLabel": "Режим просмотра diff",
|
||||||
"toolCall.diff.viewMode.split": "Раздельный",
|
"toolCall.diff.viewMode.split": "Раздельный",
|
||||||
"toolCall.diff.viewMode.unified": "Единый",
|
"toolCall.diff.viewMode.unified": "Единый",
|
||||||
|
"toolCall.diff.switchToSplit": "Переключить на раздельный вид",
|
||||||
|
"toolCall.diff.switchToUnified": "Переключить на единый вид",
|
||||||
|
"toolCall.diff.enableWordWrap": "Включить перенос слов",
|
||||||
|
"toolCall.diff.disableWordWrap": "Выключить перенос слов",
|
||||||
|
"toolCall.diff.copyPatch": "Скопировать patch",
|
||||||
|
|
||||||
"toolCall.diagnostics.title": "Диагностика",
|
"toolCall.diagnostics.title": "Диагностика",
|
||||||
"toolCall.diagnostics.ariaLabel": "Диагностика",
|
"toolCall.diagnostics.ariaLabel": "Диагностика",
|
||||||
|
|||||||
@@ -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": "内部",
|
||||||
|
|||||||
@@ -18,6 +18,11 @@ export const toolCallMessages = {
|
|||||||
"toolCall.diff.viewMode.ariaLabel": "Diff 视图模式",
|
"toolCall.diff.viewMode.ariaLabel": "Diff 视图模式",
|
||||||
"toolCall.diff.viewMode.split": "分栏",
|
"toolCall.diff.viewMode.split": "分栏",
|
||||||
"toolCall.diff.viewMode.unified": "统一",
|
"toolCall.diff.viewMode.unified": "统一",
|
||||||
|
"toolCall.diff.switchToSplit": "切换到分栏视图",
|
||||||
|
"toolCall.diff.switchToUnified": "切换到统一视图",
|
||||||
|
"toolCall.diff.enableWordWrap": "启用自动换行",
|
||||||
|
"toolCall.diff.disableWordWrap": "禁用自动换行",
|
||||||
|
"toolCall.diff.copyPatch": "复制补丁",
|
||||||
|
|
||||||
"toolCall.diagnostics.title": "诊断",
|
"toolCall.diagnostics.title": "诊断",
|
||||||
"toolCall.diagnostics.ariaLabel": "诊断",
|
"toolCall.diagnostics.ariaLabel": "诊断",
|
||||||
|
|||||||
17
packages/ui/src/lib/remote-access-addresses.test.ts
Normal file
17
packages/ui/src/lib/remote-access-addresses.test.ts
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
import assert from "node:assert/strict"
|
||||||
|
import { describe, it } from "node:test"
|
||||||
|
|
||||||
|
import { splitRemoteAddresses } from "./remote-access-addresses"
|
||||||
|
|
||||||
|
describe("splitRemoteAddresses", () => {
|
||||||
|
it("keeps the first remote address visible and collapses the rest", () => {
|
||||||
|
const result = splitRemoteAddresses([
|
||||||
|
{ ip: "127.0.0.1", family: "ipv4", scope: "loopback", remoteUrl: "https://127.0.0.1:9898" },
|
||||||
|
{ ip: "192.168.1.128", family: "ipv4", scope: "external", remoteUrl: "https://192.168.1.128:9898" },
|
||||||
|
{ ip: "172.24.96.1", family: "ipv4", scope: "external", remoteUrl: "https://172.24.96.1:9898" },
|
||||||
|
])
|
||||||
|
|
||||||
|
assert.equal(result.recommended?.ip, "192.168.1.128")
|
||||||
|
assert.deepEqual(result.hidden.map((address) => address.ip), ["172.24.96.1"])
|
||||||
|
})
|
||||||
|
})
|
||||||
14
packages/ui/src/lib/remote-access-addresses.ts
Normal file
14
packages/ui/src/lib/remote-access-addresses.ts
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
import type { NetworkAddress } from "../../../server/src/api-types"
|
||||||
|
|
||||||
|
export interface RemoteAddressGroups {
|
||||||
|
recommended: NetworkAddress | null
|
||||||
|
hidden: NetworkAddress[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export function splitRemoteAddresses(addresses: NetworkAddress[]): RemoteAddressGroups {
|
||||||
|
const remoteAddresses = addresses.filter((address) => address.scope !== "loopback")
|
||||||
|
return {
|
||||||
|
recommended: remoteAddresses[0] ?? null,
|
||||||
|
hidden: remoteAddresses.slice(1),
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -256,6 +256,55 @@
|
|||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.remote-address-disclosure {
|
||||||
|
border: 1px solid var(--border-base);
|
||||||
|
border-radius: 12px;
|
||||||
|
background: var(--surface-primary);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.remote-address-disclosure-trigger {
|
||||||
|
width: 100%;
|
||||||
|
min-height: 40px;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr auto 1fr;
|
||||||
|
align-items: center;
|
||||||
|
padding: 8px 12px;
|
||||||
|
border: 0;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--text-primary);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.remote-address-disclosure-label {
|
||||||
|
grid-column: 2;
|
||||||
|
justify-self: center;
|
||||||
|
text-align: center;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.remote-address-disclosure-chevron {
|
||||||
|
grid-column: 3;
|
||||||
|
justify-self: end;
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
transition: transform 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.remote-address-disclosure-chevron.is-expanded {
|
||||||
|
transform: rotate(180deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.remote-address-disclosure-content {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 0 10px 10px;
|
||||||
|
border-top: 1px solid var(--border-base);
|
||||||
|
}
|
||||||
|
|
||||||
.remote-qr {
|
.remote-qr {
|
||||||
margin-top: 12px;
|
margin-top: 12px;
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
.settings-screen-frame {
|
.settings-screen-frame {
|
||||||
@apply fixed inset-0 z-50 flex items-center justify-center p-4;
|
@apply fixed inset-0 z-50 flex items-center justify-center px-4;
|
||||||
|
padding-block: 5dvh;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Override .modal-surface (defined later in panels.css). */
|
/* Override .modal-surface (defined later in panels.css). */
|
||||||
.modal-surface.settings-screen-shell {
|
.modal-surface.settings-screen-shell {
|
||||||
width: min(1120px, 100%);
|
width: min(1120px, 100%);
|
||||||
height: min(88vh, 920px);
|
height: 100%;
|
||||||
max-height: none;
|
max-height: none;
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: minmax(220px, 260px) minmax(0, 1fr);
|
grid-template-columns: minmax(220px, 260px) minmax(0, 1fr);
|
||||||
@@ -278,10 +279,25 @@
|
|||||||
font-size: var(--font-size-sm);
|
font-size: var(--font-size-sm);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.settings-password-summary-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 1rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-password-summary-copy {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.25rem;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
.settings-password-actions {
|
.settings-password-actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: flex-start;
|
justify-content: flex-end;
|
||||||
margin-top: 0.75rem;
|
margin-top: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.settings-form-group {
|
.settings-form-group {
|
||||||
|
|||||||
@@ -321,6 +321,7 @@
|
|||||||
|
|
||||||
.tool-call-diff-shell {
|
.tool-call-diff-shell {
|
||||||
padding: 0;
|
padding: 0;
|
||||||
|
scrollbar-gutter: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tool-call-diff-viewer {
|
.tool-call-diff-viewer {
|
||||||
@@ -343,6 +344,8 @@
|
|||||||
.tool-call-diff-shell .tool-call-diff-viewer {
|
.tool-call-diff-shell .tool-call-diff-viewer {
|
||||||
max-height: none;
|
max-height: none;
|
||||||
overflow: visible;
|
overflow: visible;
|
||||||
|
width: 100%;
|
||||||
|
min-width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tool-call-diff-toolbar-label {
|
.tool-call-diff-toolbar-label {
|
||||||
@@ -513,6 +516,84 @@
|
|||||||
font-size: var(--font-size-xs);
|
font-size: var(--font-size-xs);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.tool-call-diff-shell .tool-call-diff-viewer .diff-line-content,
|
||||||
|
.tool-call-diff-shell .tool-call-diff-viewer .diff-line-hunk-content,
|
||||||
|
.tool-call-diff-shell .tool-call-diff-viewer .diff-line-old-content,
|
||||||
|
.tool-call-diff-shell .tool-call-diff-viewer .diff-line-new-content {
|
||||||
|
padding-right: 0 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tool-call-diff-shell[data-diff-mode="unified"] .tool-call-diff-viewer .diff-line-num {
|
||||||
|
padding-left: 1px !important;
|
||||||
|
padding-right: 1px !important;
|
||||||
|
text-align: left !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tool-call-diff-shell[data-diff-mode="unified"] .tool-call-diff-viewer .unified-diff-table {
|
||||||
|
table-layout: fixed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tool-call-diff-shell[data-diff-mode="unified"] .tool-call-diff-viewer .unified-diff-table-num-col {
|
||||||
|
width: auto !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tool-call-diff-shell[data-diff-mode="unified"] .tool-call-diff-viewer .tool-call-diff-compact-line-number {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
text-align: left;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tool-call-diff-shell[data-diff-mode="split"] .tool-call-diff-viewer .diff-line-old-num,
|
||||||
|
.tool-call-diff-shell[data-diff-mode="split"] .tool-call-diff-viewer .diff-line-new-num,
|
||||||
|
.tool-call-diff-shell[data-diff-mode="split"] .tool-call-diff-viewer .diff-line-hunk-action {
|
||||||
|
padding-left: 2px !important;
|
||||||
|
padding-right: 2px !important;
|
||||||
|
text-align: left !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tool-call-diff-shell[data-diff-mode="split"] .tool-call-diff-viewer .diff-line-old-num,
|
||||||
|
.tool-call-diff-shell[data-diff-mode="split"] .tool-call-diff-viewer .diff-line-new-num,
|
||||||
|
.tool-call-diff-shell[data-diff-mode="split"] .tool-call-diff-viewer .diff-line-old-num [data-line-num],
|
||||||
|
.tool-call-diff-shell[data-diff-mode="split"] .tool-call-diff-viewer .diff-line-new-num [data-line-num] {
|
||||||
|
white-space: nowrap !important;
|
||||||
|
word-break: normal !important;
|
||||||
|
overflow-wrap: normal !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tool-call-diff-shell[data-diff-mode="split"] .tool-call-diff-viewer .diff-line-hunk-action {
|
||||||
|
padding-top: 1px !important;
|
||||||
|
padding-bottom: 1px !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tool-call-diff-shell .tool-call-diff-viewer .diff-line-content-item {
|
||||||
|
padding-left: 1.1em !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tool-call-diff-shell .tool-call-diff-viewer .diff-line-content-operator {
|
||||||
|
margin-left: -1.1em !important;
|
||||||
|
width: 0.9em !important;
|
||||||
|
min-width: 0.9em !important;
|
||||||
|
text-indent: 0 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.tool-call-diff-shell[data-diff-mode="unified"] .tool-call-diff-viewer .unified-diff-table-wrapper {
|
||||||
|
--diff-aside-width: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tool-call-diff-shell .tool-call-diff-viewer .diff-line-content-item {
|
||||||
|
padding-left: 1.5em !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tool-call-diff-shell .tool-call-diff-viewer .diff-line-content-operator {
|
||||||
|
margin-left: -1.5em !important;
|
||||||
|
width: 1.1em !important;
|
||||||
|
min-width: 1.1em !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.tool-call-markdown .markdown-code-block {
|
.tool-call-markdown .markdown-code-block {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
border-radius: 0;
|
border-radius: 0;
|
||||||
|
|||||||
@@ -40,6 +40,7 @@ export interface RenderCache {
|
|||||||
html: string
|
html: string
|
||||||
theme?: string
|
theme?: string
|
||||||
mode?: string
|
mode?: string
|
||||||
|
wrap?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PendingPermissionState {
|
export interface PendingPermissionState {
|
||||||
|
|||||||
Reference in New Issue
Block a user