Compare commits

..

13 Commits

Author SHA1 Message Date
Pascal André
04fc28c492 feat(tauri): support self-signed remote HTTPS via server-backed proxy (#333)
## Summary

- add a server-backed HTTPS proxy flow for Tauri remote windows so
self-signed remote HTTPS works with the local CLI TLS assets and desktop
auth/cookie handling
- manage remote proxy sessions through `packages/server` with
per-session bootstrap, local-only cleanup, and explicit session
lifecycle handling
- support the Tauri desktop flow across environments, including packaged
Windows builds, `tauri dev`, and updated Linux/macOS handling for the
new local HTTPS proxy path

## Testing

- `npm run build --workspace @neuralnomads/codenomad`
- `cargo check`
- `npm run build --workspace @codenomad/tauri-app`
- Windows smoke test for concurrent remote proxy bootstrap sessions
- Windows manual validation of packaged Tauri remote connection flow

## Notes

- Windows was validated end-to-end.
- Linux and macOS code paths were updated for the new proxy flow, but
runtime validation on those platforms is still pending.

---------

Co-authored-by: Shantur Rathore <i@shantur.com>
2026-04-19 23:26:55 +01:00
Shantur Rathore
623a09fd7e fix(ui): stabilize long reply hold during streaming 2026-04-19 19:56:48 +01:00
Shantur Rathore
b00aa7ef84 fix(build): add Windows ARM64 Rollup native package 2026-04-19 08:49:23 +01:00
Pascal André
acfa265595 fix(build): align Rollup native packages with supported platforms (#337)
Fixes #324

## Summary
- declare root Rollup optional dependencies for the repo's current
supported build matrix: macOS x64/arm64, Linux x64/arm64, and Windows
x64
- pin those root platform packages to the same Rollup version already
used by the repo
- keep the existing workflow/manual-install fallback steps in place for
now

## Validation
- regenerated `package-lock.json` with `npm install --package-lock-only
--ignore-scripts`
- verified the root package entry now records the supported platform
packages under `optionalDependencies`
- kept the change scoped to the platforms currently represented in
workflows and `packages/tauri-app/scripts/prebuild.js`
2026-04-19 08:40:49 +01:00
Pascal André
35b171764e fix(desktop): align Electron package and runtime app ids (#342)
Follow-up from #334

## Summary
- align the Electron package `build.appId` with the runtime identifier
already used in `app.setAppUserModelId(...)`
- remove the mismatch between packaged desktop identity and runtime
desktop identity
- keep the change narrowly scoped to identifier consistency only

## Validation
- verified the previous mismatch in `packages/electron-app/package.json`
vs `packages/electron-app/electron/main/main.ts`
- updated the packaging id to match the runtime id exactly
2026-04-18 23:56:58 +01:00
Pascal André
6b53ab2d73 fix(ui): prevent session status labels from being retranslated (#339)
Fixes #273

## Summary
- mark the session list header label as non-translatable
- mark compact session status badges as non-translatable
- prevent browser/page translation from duplicating already localized
labels like the repeated idle badge shown in #273

## Validation
- `npm run build --workspace @codenomad/ui`
2026-04-18 23:49:38 +01:00
Pascal André
1b829094ef fix(desktop): improve Linux desktop icon integration (#334)
Refs #330

## Summary
- add standard Linux hicolor icon sizes to the Tauri package outputs
- enable the GTK app id on Linux and ship a matching reverse-DNS desktop
entry alias for shell association
- mark the alias desktop entry `NoDisplay=true` so it does not surface
as a duplicate launcher in desktop menus
- include the same alias desktop entry for AppImage so the fix is not
limited to deb/rpm packages

## Validation
- confirmed in the Linux VM that the desktop-integrated launch no longer
shows the generic taskbar icon
- verified the alias desktop entry is now hidden from app menus via
`NoDisplay=true`
- attempted a fresh `tauri build --bundles deb`; the build still hits
the known optional `@tauri-apps/cli` native-binding issue in this
workspace after prebuild, not a code/config error from this PR
2026-04-18 23:46:03 +01:00
Pascal André
e28e9f5879 fix(desktop): show explicit missing Node errors (#336)
Fixes #294

## Summary
- detect missing desktop Node runtimes before spawning the bundled CLI
- return a clear error message that tells users to install Node.js or
set `NODE_BINARY`
- handle both direct spawns and desktop-shell launches consistently

## Validation
- `npm run bundle:server --workspace @codenomad/tauri-app && cargo build
--manifest-path packages/tauri-app/src-tauri/Cargo.toml`
- exercised the missing-runtime path in the Linux VM by launching with
an invalid `NODE_BINARY`
2026-04-18 23:39:39 +01:00
Pascal André
cb84547c88 fix(desktop): source shell rc before launching CLI (#332)
Fixes #326

## Summary
- source the user's bash or zsh rc before launching the bundled CLI from
Tauri
- use `-l -i -c` for zsh so shell-managed Node runtimes are available in
launcher-started sessions
- fixes the reproduced Linux launcher case where the app exits with `CLI
exited early: exit status: 127` while terminal launches work

## Validation
- reproduced the failure with the released Tauri `v0.14.0` Linux binary
- verified the patched binary succeeds under the same launcher-like
environment
- ran `cargo build` on the dev-based PR branch
2026-04-18 23:34:49 +01:00
VooDisss
e022a158eb improve delete worktree failure diagnostics (#302)
## Summary
- move delete-worktree failures out of transient toast-only UX and keep
them inline in the delete modal
- add parsed diagnostics for common failure modes, including a short
summary, likely cause, and suggested next step
- make the raw error easier to review and share with raw and sanitized
copy actions

Closes #301.

## BEFORE:

<img width="1127" height="860" alt="image"
src="https://github.com/user-attachments/assets/dd09ba1e-be8c-450c-a1dd-f1cde2a48802"
/>

## AFTER: 

<img width="1384" height="835" alt="image"
src="https://github.com/user-attachments/assets/6b0d1459-21fa-4264-9e54-45540f584538"
/>

## Problem
Before this change, delete-worktree failures were difficult to work
with:

1. The failure message was effectively raw backend or git output.
2. Users had to infer the meaning of the error themselves.
3. The UI did not explain what likely went wrong or what to do next.
4. Sharing the error for debugging was awkward when it included
machine-local absolute paths.
5. The confirmation modal was not being used as the primary diagnostic
surface for a destructive action that frequently fails for
understandable reasons.

This was especially frustrating for common cases such as:
- modified or untracked files in the worktree
- a process still using the worktree directory
- permission errors on Windows
- missing worktree directories or stale worktree records

## What changed

### Modal failure UX
- keep delete failures inline inside
`packages/ui/src/components/worktree-selector.tsx`
- clear modal-local error state when opening or closing the dialog
- keep the success toast on successful deletion, but use the modal
itself for failure presentation

### Human-readable diagnostics
- parse JSON-shaped backend error payloads such as `{"error":"..."}`
before classification
- classify common delete failure patterns into:
  - `localChanges`
  - `inUse`
  - `notFound`
  - `permissionDenied`
  - `unknown`
- render three user-facing lines above the raw error:
  - summary
  - likely cause
  - suggested next step

### Copy flows
- add `Copy error` for the original failure text
- add `Copy sanitized` to redact common absolute path and username
patterns before copying

### Modal content and sizing
- present the target worktree in a simpler two-line summary block
- update the delete description text to plain English: `Deletes this
branch worktree and its local folder.`
- size the delete modal deliberately for desktop use while allowing
vertical expansion to the viewport limit before scrolling

### i18n coverage
- add the new delete diagnostic strings across all currently supported
locales touched by this area:
  - `en`
  - `es`
  - `fr`
  - `he`
  - `ja`
  - `ru`
  - `zh-Hans`

## Why this approach
- It keeps the backend contract unchanged and solves the UX problem
where it occurs.
- It preserves access to the raw failure text instead of hiding
implementation detail entirely.
- It gives users immediate guidance without forcing them to translate
git errors into next actions.
- It improves bug reporting without requiring a separate logging or
export workflow.

## Not included
- server-side preflight guards that block delete when the worktree is
still assigned or in use
- process-aware worktree locking detection
- automatic retry or force-delete-and-retry flows

Those are useful follow-ups, but this PR is intentionally scoped to
failure presentation and debuggability.

## Files changed
- `packages/ui/src/components/worktree-selector.tsx`
- `packages/ui/src/lib/i18n/messages/en/instance.ts`
- `packages/ui/src/lib/i18n/messages/es/instance.ts`
- `packages/ui/src/lib/i18n/messages/fr/instance.ts`
- `packages/ui/src/lib/i18n/messages/he/instance.ts`
- `packages/ui/src/lib/i18n/messages/ja/instance.ts`
- `packages/ui/src/lib/i18n/messages/ru/instance.ts`
- `packages/ui/src/lib/i18n/messages/zh-Hans/instance.ts`

## Validation
- `npm run typecheck --workspace @codenomad/ui`
- `npm run build --workspace @codenomad/ui`
- `npm run typecheck --workspace @neuralnomads/codenomad-electron-app`

## Notes for reviewers
- The error classifier is intentionally heuristic and string-based. It
is meant to improve the common cases without increasing backend
coupling.
- The sanitized copy flow is conservative and focused on path and
username redaction, not full structured log scrubbing.

---------

Co-authored-by: Shantur Rathore <i@shantur.com>
2026-04-17 17:12:17 +01:00
VooDisss
9d9a6a79ec Git diff monaco redesign (#304)
## Summary

Fixes #303.

This PR redesigns the Git Changes Monaco diff gutter so unified and
split view both use a more intentional, space-efficient Monaco
presentation while preserving Monaco's performance on large diffs.

The final behavior includes:

- `Compact` and `Normal` gutter modes for Git Changes
- dynamic gutter sizing based on actual line-number digit counts
- independent original/modified number-column sizing where needed
- split-view fixes for both wasted left inset and line-number/sign
overlap
- persisted gutter-mode selection
- localized user-facing labels for the control

## Visual comparison

### Unified view before

<img width="465" height="353" alt="Unified view before"
src="https://github.com/user-attachments/assets/0c061f25-f20a-4127-a85d-aee1161611c7"
/>

### Unified view after

<img width="634" height="240" alt="Unified view after"
src="https://github.com/user-attachments/assets/f2dfd952-89ed-4fdd-83db-a05f19f023b2"
/>

### Split view before

<img width="596" height="335" alt="Split view before"
src="https://github.com/user-attachments/assets/09bfbe41-9438-4801-b181-49a9d19d5bb8"
/>

### Split view after

<img width="640" height="338" alt="Split view after"
src="https://github.com/user-attachments/assets/fc3618ef-474f-4217-bb21-5ffd53eb4e01"
/>

<!-- If you want to replace these screenshots later, keep the four
sections above and swap the image URLs. -->

## What changed

### Unified view

- added two Git Changes Monaco gutter presentations:
  - `Compact`
  - `Normal`
- kept compact as the tighter single-column-feel unified gutter
- kept normal as the wider Monaco-style unified gutter
- made unified gutter sizing respond to actual line-number digit counts
instead of fixed assumptions
- made normal mode size the visible number columns independently when
one side needs more width than the other

### Split view

- added dynamic split gutter sizing derived from actual before/after
line counts
- made split original and modified number columns size independently
- fixed the modified-pane overlap where larger line numbers could
collide with the `+` lane
- fixed the original-pane wasted left inset caused by Monaco reserving
an empty original-side glyph-margin lane

### Persistence and UI

- persisted the selected gutter mode in preferences so it survives
reloads
- moved the gutter-mode control out of the Git Changes toolbar and into
Appearance settings
- renamed the visible settings options to `Compact` and `Normal`

### i18n

- removed hardcoded user-facing gutter toggle strings
- added localized keys for the gutter control labels and titles used by
the Git Changes surface

## Implementation notes

- Monaco remains the active Git Changes renderer throughout
- gutter sizing logic is centralized in
`packages/ui/src/components/file-viewer/monaco-diff-viewer.tsx`
- CSS is used only for narrow presentation adjustments such as the 4px
left inset and the split original-pane glyph-margin correction
- the persisted gutter-mode preference is the source of truth for the
selected presentation

## Review focus

- unified `Compact` mode should feel tight without clipping or overlap
- unified `Normal` mode should remain wider and readable
- 3-digit and 4-digit line numbers should not collide with the sign lane
- split original pane should no longer show wasted left inset before the
first visible number column
- split modified pane should not leave conspicuous dead space or collide
with the `+` lane as digit counts grow
- selected gutter mode should persist after reload

---------

Co-authored-by: Shantur Rathore <i@shantur.com>
2026-04-17 17:04:10 +01:00
Shantur Rathore
82a7c95dba fix(ui): separate prompt composer action columns
Keep the textarea width independent from the prompt controls so wrapping matches the visible layout. Split secondary controls from the primary stop/send rail to preserve the original action column width and add a matching divider.
2026-04-17 16:12:48 +01:00
Shantur Rathore
313a0e579e fix(ui): hold streaming replies once top leaves view 2026-04-17 15:20:48 +01:00
48 changed files with 1548 additions and 1801 deletions

973
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -29,8 +29,14 @@
"google-auth-library": "^10.5.0"
},
"devDependencies": {
"@esbuild/darwin-arm64": "^0.28.0",
"@rollup/rollup-darwin-arm64": "^4.60.2",
"baseline-browser-mapping": "^2.9.11"
},
"optionalDependencies": {
"@rollup/rollup-darwin-arm64": "4.52.5",
"@rollup/rollup-darwin-x64": "4.52.5",
"@rollup/rollup-linux-arm64-gnu": "4.52.5",
"@rollup/rollup-linux-x64-gnu": "4.52.5",
"@rollup/rollup-win32-arm64-msvc": "4.52.5",
"@rollup/rollup-win32-x64-msvc": "4.52.5"
}
}

View File

@@ -62,7 +62,7 @@
"vite-plugin-solid": "^2.10.0"
},
"build": {
"appId": "ai.opencode.client",
"appId": "ai.neuralnomads.codenomad.client",
"productName": "CodeNomad",
"directories": {
"output": "release",

View File

@@ -343,6 +343,7 @@ export interface RemoteProxySessionCreateRequest {
}
export interface RemoteProxySessionCreateResponse {
sessionId: string
windowUrl: string
}

View File

@@ -376,10 +376,6 @@ async function main() {
})
: null
if (uiResolution.uiDevServerUrl && options.https) {
throw new InvalidArgumentError("UI dev proxy is only supported with --https=false --http=true")
}
const remoteAccessEnabled = options.host === "0.0.0.0" || !isLoopbackHost(options.host)
const clientConnectionManager = new ClientConnectionManager(logger.child({ component: "client-connections" }))

View File

@@ -0,0 +1,248 @@
import assert from "node:assert/strict"
import { after, afterEach, describe, it } from "node:test"
import fs from "node:fs"
import http, { type IncomingMessage, type ServerResponse } from "node:http"
import os from "node:os"
import path from "node:path"
import { Agent, fetch } from "undici"
import type { AuthManager } from "../../auth/manager"
import type { Logger } from "../../logger"
import { RemoteProxySessionManager } from "../remote-proxy"
import { resolveHttpsOptions } from "../tls"
const sharedTempDir = fs.mkdtempSync(path.join(os.tmpdir(), "codenomad-remote-proxy-test-"))
const sharedTls = resolveHttpsOptions({
enabled: true,
configDir: sharedTempDir,
host: "127.0.0.1",
logger: createStubLogger(),
})
if (!sharedTls) {
throw new Error("Failed to generate HTTPS options for remote proxy tests")
}
const sharedHttpsOptions = sharedTls.httpsOptions
const httpsDispatcher = new Agent({ connect: { rejectUnauthorized: false } })
const managers = new Set<RemoteProxySessionManager>()
afterEach(async () => {
for (const manager of managers) {
await disposeManager(manager)
}
managers.clear()
})
after(() => {
fs.rmSync(sharedTempDir, { recursive: true, force: true })
httpsDispatcher.close().catch(() => {})
})
describe("RemoteProxySessionManager", () => {
it("blocks proxying before activation and keeps bootstrap tokens scoped per session", async () => {
await withUpstreamServer(async (upstreamBaseUrl) => {
const manager = createSessionManager()
const session1 = await createSession(manager, `${upstreamBaseUrl}/base`)
const session2 = await createSession(manager, `${upstreamBaseUrl}/base`)
const blocked = await proxyFetch(`${session1.proxyOrigin}/status`)
assert.equal(blocked.status, 403)
const wrongTokenResponse = await proxyFetch(`${session1.proxyOrigin}/__codenomad/api/auth/token`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ token: session2.token }),
})
assert.equal(wrongTokenResponse.status, 401)
assert.equal(await activateSession(session1), true)
assert.equal(await activateSession(session2), true)
}, (req, res) => {
res.writeHead(200, { "content-type": "text/plain" })
res.end(req.url ?? "")
})
})
it("preserves remote base paths and rewrites same-origin redirects to the local proxy origin", async () => {
await withUpstreamServer(async (upstreamBaseUrl) => {
const manager = createSessionManager()
const session = await createSession(manager, `${upstreamBaseUrl}/base`)
await activateSession(session)
const apiResponse = await proxyFetch(`${session.proxyOrigin}/api/auth/status?foo=bar`)
assert.equal(apiResponse.status, 200)
assert.equal(await apiResponse.text(), "/base/api/auth/status?foo=bar")
const redirectResponse = await proxyFetch(`${session.proxyOrigin}/redirect`, { redirect: "manual" })
assert.equal(redirectResponse.status, 302)
assert.equal(redirectResponse.headers.get("location"), `${session.proxyOrigin}/base/after?ok=1`)
}, (req, res) => {
const requestUrl = req.url ?? ""
if (requestUrl === "/base/redirect") {
res.writeHead(302, { location: "/base/after?ok=1" })
res.end()
return
}
res.writeHead(200, { "content-type": "text/plain" })
res.end(requestUrl)
})
})
it("rewrites set-cookie names for the proxy and restores cookie names on proxied requests", async () => {
await withUpstreamServer(async (upstreamBaseUrl) => {
const manager = createSessionManager()
const session = await createSession(manager, `${upstreamBaseUrl}/base`)
await activateSession(session)
const loginResponse = await proxyFetch(`${session.proxyOrigin}/login`)
assert.equal(loginResponse.status, 200)
const setCookie = getSetCookie(loginResponse)[0]
assert.match(setCookie, /^cnrp_[0-9a-f]+_session=abc123/i)
assert.doesNotMatch(setCookie, /domain=/i)
const cookieHeader = setCookie.split(";", 1)[0]
const whoamiResponse = await proxyFetch(`${session.proxyOrigin}/whoami`, {
headers: { cookie: cookieHeader },
})
assert.equal(await whoamiResponse.text(), "session=abc123")
}, (req, res) => {
const requestUrl = req.url ?? ""
if (requestUrl === "/base/login") {
res.writeHead(200, {
"content-type": "text/plain",
"set-cookie": "session=abc123; Path=/; Secure; HttpOnly; Domain=127.0.0.1",
})
res.end("ok")
return
}
if (requestUrl === "/base/whoami") {
res.writeHead(200, { "content-type": "text/plain" })
res.end(req.headers.cookie ?? "")
return
}
res.writeHead(404, { "content-type": "text/plain" })
res.end(requestUrl)
})
})
it("supports explicit deletion and idle cleanup of sessions", async () => {
await withUpstreamServer(async (upstreamBaseUrl) => {
const manager = createSessionManager()
const session = await createSession(manager, `${upstreamBaseUrl}/base`)
assert.equal(await manager.deleteSession(session.sessionId), true)
assert.equal(await manager.deleteSession(session.sessionId), false)
const session3 = await createSession(manager, `${upstreamBaseUrl}/base`)
const internalSessions = (manager as any).sessions as Map<string, { lastAccessAt: number }>
const internalCleanup = (manager as any).cleanupExpiredSessions as () => Promise<void>
internalSessions.get(session3.sessionId)!.lastAccessAt = Date.now() - 31 * 60_000
await internalCleanup.call(manager)
assert.equal(internalSessions.has(session3.sessionId), false)
assert.equal(await manager.deleteSession(session3.sessionId), false)
}, (_req, res) => {
res.writeHead(200, { "content-type": "text/plain" })
res.end("ok")
})
})
})
function createSessionManager() {
const manager = new RemoteProxySessionManager({
authManager: {
isLoopbackRequest: () => true,
} as unknown as AuthManager,
logger: createStubLogger(),
httpsOptions: sharedHttpsOptions,
})
managers.add(manager)
return manager
}
async function createSession(manager: RemoteProxySessionManager, baseUrl: string) {
const created = await manager.createSession(baseUrl, false)
const windowUrl = new URL(created.windowUrl)
return {
sessionId: created.sessionId,
windowUrl,
proxyOrigin: windowUrl.origin,
token: decodeURIComponent(windowUrl.hash.replace(/^#/, "")),
}
}
async function activateSession(session: { proxyOrigin: string; token: string }) {
const response = await proxyFetch(`${session.proxyOrigin}/__codenomad/api/auth/token`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ token: session.token }),
})
if (!response.ok) {
return false
}
const body = (await response.json()) as { ok?: boolean }
return body.ok === true
}
function getSetCookie(response: Awaited<ReturnType<typeof fetch>>): string[] {
const values = (response.headers as any).getSetCookie?.() as string[] | undefined
if (Array.isArray(values) && values.length > 0) {
return values
}
const fallback = response.headers.get("set-cookie")
return fallback ? [fallback] : []
}
async function proxyFetch(url: string, init?: Parameters<typeof fetch>[1]) {
return fetch(url, { dispatcher: httpsDispatcher, ...init })
}
async function disposeManager(manager: RemoteProxySessionManager) {
const sessions = Array.from(((manager as any).sessions as Map<string, unknown>).keys())
for (const sessionId of sessions) {
await manager.deleteSession(sessionId)
}
clearInterval((manager as any).cleanupTimer as NodeJS.Timeout)
}
async function withUpstreamServer(
callback: (baseUrl: string) => Promise<void>,
handler: (req: IncomingMessage, res: ServerResponse<IncomingMessage>) => void,
) {
const server = http.createServer(handler)
await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", () => resolve()))
try {
const address = server.address()
if (!address || typeof address === "string") {
throw new Error("Failed to resolve upstream server address")
}
await callback(`http://127.0.0.1:${address.port}`)
} finally {
await new Promise<void>((resolve, reject) => server.close((error) => (error ? reject(error) : resolve())))
}
}
function createStubLogger(): Logger {
const logger = {
info() {},
warn() {},
error() {},
child() {
return logger
},
}
return logger as unknown as Logger
}

View File

@@ -202,7 +202,12 @@ export function createHttpServer(deps: HttpServerDeps) {
publicPagePaths.add("/auth/token")
}
if (publicApiPaths.has(pathname) || publicPagePaths.has(pathname)) {
const isLoopbackRemoteProxyDelete =
request.method === "DELETE" &&
pathname.startsWith("/api/remote-proxy/sessions/") &&
deps.authManager.isLoopbackRequest(request)
if (publicApiPaths.has(pathname) || publicPagePaths.has(pathname) || isLoopbackRemoteProxyDelete) {
done()
return
}

View File

@@ -1,6 +1,7 @@
import Fastify, { type FastifyInstance, type FastifyReply, type FastifyRequest } from "fastify"
import { randomBytes, randomUUID } from "crypto"
import { Readable } from "stream"
import { pipeline } from "stream/promises"
import { Agent, fetch } from "undici"
import type { AuthManager } from "../auth/manager"
import type { Logger } from "../logger"
@@ -12,6 +13,7 @@ const SESSION_IDLE_TTL_MS = 30 * 60_000
interface RemoteProxySession {
id: string
bootstrapToken: string
targetBaseUrl: URL
skipTlsVerify: boolean
localBaseUrl: URL
@@ -31,6 +33,11 @@ export interface RemoteProxySessionManagerOptions {
httpsOptions?: { key: string | Buffer; cert: string | Buffer; ca?: string | Buffer }
}
export interface RemoteProxySessionCreateResult {
sessionId: string
windowUrl: string
}
export class RemoteProxySessionManager {
private readonly sessions = new Map<string, RemoteProxySession>()
private readonly cleanupTimer: NodeJS.Timeout
@@ -42,24 +49,21 @@ export class RemoteProxySessionManager {
this.cleanupTimer.unref()
}
async createSession(baseUrl: string, skipTlsVerify: boolean): Promise<string> {
async createSession(baseUrl: string, skipTlsVerify: boolean): Promise<RemoteProxySessionCreateResult> {
if (!this.options.httpsOptions) {
throw new Error("Local HTTPS is required for remote proxy sessions")
}
const targetBaseUrl = normalizeBaseUrl(baseUrl)
const token = this.options.authManager.issueBootstrapToken()
if (!token) {
throw new Error("Bootstrap token generation is unavailable")
}
const sessionId = randomUUID()
const bootstrapToken = randomBytes(32).toString("base64url")
const dispatcher = skipTlsVerify ? new Agent({ connect: { rejectUnauthorized: false } }) : undefined
const app = Fastify({ logger: false, https: this.options.httpsOptions })
let session: RemoteProxySession | null = null
app.removeAllContentTypeParsers()
app.addContentTypeParser("*", (req, body, done) => done(null, body))
// Preserve raw request bodies for proxying while still letting token JSON parse from Buffer.
app.addContentTypeParser("*", { parseAs: "buffer" }, (_req, body, done) => done(null, body))
app.get(BOOTSTRAP_PAGE_PATH, async (request, reply) => {
if (!this.options.authManager.isLoopbackRequest(request)) {
@@ -79,14 +83,14 @@ export class RemoteProxySessionManager {
return
}
const body = parseTokenBody(request.body)
if (!this.options.authManager.consumeBootstrapToken(body.token)) {
reply.code(401).send({ error: "Invalid token" })
if (!session) {
reply.code(503).send({ error: "Remote proxy session is unavailable" })
return
}
if (!session) {
reply.code(503).send({ error: "Remote proxy session is unavailable" })
const body = parseTokenBody(request.body)
if (body.token !== session.bootstrapToken) {
reply.code(401).send({ error: "Invalid token" })
return
}
@@ -133,11 +137,12 @@ export class RemoteProxySessionManager {
session = {
id: sessionId,
bootstrapToken,
targetBaseUrl,
skipTlsVerify,
localBaseUrl,
entryUrl,
bootstrapUrl: `${localBaseUrl.origin}${BOOTSTRAP_PAGE_PATH}?returnTo=${encodeURIComponent(returnTo)}#${encodeURIComponent(token)}`,
bootstrapUrl: `${localBaseUrl.origin}${BOOTSTRAP_PAGE_PATH}?returnTo=${encodeURIComponent(returnTo)}#${encodeURIComponent(bootstrapToken)}`,
activated: false,
cookiePrefix: `cnrp_${randomBytes(6).toString("hex")}_`,
app,
@@ -152,7 +157,11 @@ export class RemoteProxySessionManager {
"Created remote proxy session",
)
return session.bootstrapUrl
return { sessionId, windowUrl: session.bootstrapUrl }
}
async deleteSession(sessionId: string): Promise<boolean> {
return this.disposeSession(sessionId)
}
private async cleanupExpiredSessions() {
@@ -165,16 +174,17 @@ export class RemoteProxySessionManager {
}
}
private async disposeSession(sessionId: string) {
private async disposeSession(sessionId: string): Promise<boolean> {
const session = this.sessions.get(sessionId)
if (!session) {
return
return false
}
this.sessions.delete(sessionId)
session.dispatcher?.close().catch(() => {})
await session.app.close().catch(() => {})
this.options.logger.info({ sessionId }, "Disposed remote proxy session")
return true
}
}
@@ -336,7 +346,9 @@ async function proxyRequest(args: {
return
}
reply.send(Readable.fromWeb(response.body as any))
reply.hijack()
reply.raw.writeHead(reply.statusCode, toOutgoingHeaders(reply.getHeaders()))
await pipeline(Readable.fromWeb(response.body as any), reply.raw)
} catch (error) {
logger.error({ err: error, upstreamUrl }, "Failed to proxy remote session request")
if (!reply.sent) {
@@ -394,7 +406,12 @@ function filterRequestHeaders(
for (const [key, value] of Object.entries(headers ?? {})) {
if (!value) continue
const lower = key.toLowerCase()
if (isHopByHopHeader(lower) || lower === "host" || lower === "content-length") {
if (
isHopByHopHeader(lower) ||
lower === "host" ||
lower === "content-length" ||
lower === "accept-encoding"
) {
continue
}
if (lower === "origin") {
@@ -452,7 +469,12 @@ function applyResponseHeaders(reply: FastifyReply, response: any, session: Remot
response.headers.forEach((value: string, key: string) => {
const lower = key.toLowerCase()
if (isHopByHopHeader(lower) || lower === "set-cookie") {
if (
isHopByHopHeader(lower) ||
lower === "set-cookie" ||
lower === "content-length" ||
lower === "content-encoding"
) {
return
}
@@ -465,6 +487,17 @@ function applyResponseHeaders(reply: FastifyReply, response: any, session: Remot
})
}
function toOutgoingHeaders(headers: ReturnType<FastifyReply["getHeaders"]>): Record<string, string | string[]> {
const next: Record<string, string | string[]> = {}
for (const [key, value] of Object.entries(headers)) {
if (value === undefined) {
continue
}
next[key] = Array.isArray(value) ? value.map(String) : String(value)
}
return next
}
function rewriteSetCookie(cookie: string, cookiePrefix: string): string {
const parts = cookie.split(";").map((part) => part.trim())
const first = parts.shift() ?? ""

View File

@@ -1,6 +1,7 @@
import type { FastifyInstance } from "fastify"
import { z } from "zod"
import type { RemoteProxySessionCreateResponse } from "../../api-types"
import { isLoopbackAddress } from "../../auth/http-auth"
import type { Logger } from "../../logger"
import type { RemoteProxySessionManager } from "../remote-proxy"
@@ -14,16 +15,40 @@ const CreateSessionSchema = z.object({
skipTlsVerify: z.boolean().optional(),
})
const SessionParamsSchema = z.object({
id: z.string().uuid(),
})
export function registerRemoteProxyRoutes(app: FastifyInstance, deps: RouteDeps) {
app.post("/api/remote-proxy/sessions", async (request, reply): Promise<RemoteProxySessionCreateResponse | { error: string }> => {
try {
const body = CreateSessionSchema.parse(request.body ?? {})
const windowUrl = await deps.sessionManager.createSession(body.baseUrl, Boolean(body.skipTlsVerify))
return { windowUrl }
return await deps.sessionManager.createSession(body.baseUrl, Boolean(body.skipTlsVerify))
} catch (error) {
deps.logger.warn({ err: error }, "Failed to create remote proxy session")
reply.code(400)
return { error: error instanceof Error ? error.message : "Failed to create remote proxy session" }
}
})
app.delete("/api/remote-proxy/sessions/:id", async (request, reply): Promise<{ ok: boolean } | { error: string }> => {
if (!isLoopbackAddress(request.socket.remoteAddress)) {
reply.code(404)
return { error: "Not found" }
}
try {
const params = SessionParamsSchema.parse(request.params ?? {})
const deleted = await deps.sessionManager.deleteSession(params.id)
if (!deleted) {
reply.code(404)
return { error: "Remote proxy session not found" }
}
return { ok: true }
} catch (error) {
deps.logger.warn({ err: error }, "Failed to delete remote proxy session")
reply.code(400)
return { error: error instanceof Error ? error.message : "Failed to delete remote proxy session" }
}
})
}

View File

@@ -47,15 +47,6 @@ version = "1.0.102"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c"
[[package]]
name = "arc-swap"
version = "1.9.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6a3a1fd6f75306b68087b831f025c712524bcb19aad54e557b1129cfa0a2b207"
dependencies = [
"rustversion",
]
[[package]]
name = "async-broadcast"
version = "0.7.2"
@@ -244,83 +235,6 @@ dependencies = [
"fs_extra",
]
[[package]]
name = "axum"
version = "0.7.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "edca88bc138befd0323b20752846e6587272d3b03b0343c8ea28a6f819e6e71f"
dependencies = [
"async-trait",
"axum-core",
"bytes",
"futures-util",
"http",
"http-body",
"http-body-util",
"hyper",
"hyper-util",
"itoa",
"matchit",
"memchr",
"mime",
"percent-encoding",
"pin-project-lite",
"rustversion",
"serde",
"serde_json",
"serde_path_to_error",
"serde_urlencoded",
"sync_wrapper",
"tokio",
"tower",
"tower-layer",
"tower-service",
"tracing",
]
[[package]]
name = "axum-core"
version = "0.4.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "09f2bd6146b97ae3359fa0cc6d6b376d9539582c7b4220f041a33ec24c226199"
dependencies = [
"async-trait",
"bytes",
"futures-util",
"http",
"http-body",
"http-body-util",
"mime",
"pin-project-lite",
"rustversion",
"sync_wrapper",
"tower-layer",
"tower-service",
"tracing",
]
[[package]]
name = "axum-server"
version = "0.7.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c1ab4a3ec9ea8a657c72d99a03a824af695bd0fb5ec639ccbd9cd3543b41a5f9"
dependencies = [
"arc-swap",
"bytes",
"fs-err",
"http",
"http-body",
"hyper",
"hyper-util",
"pin-project-lite",
"rustls",
"rustls-pemfile",
"rustls-pki-types",
"tokio",
"tokio-rustls",
"tower-service",
]
[[package]]
name = "base64"
version = "0.21.7"
@@ -586,17 +500,11 @@ name = "codenomad-tauri"
version = "0.14.0"
dependencies = [
"anyhow",
"axum",
"axum-server",
"base64 0.22.1",
"bytes",
"dirs 5.0.1",
"futures-util",
"keepawake",
"libc",
"once_cell",
"parking_lot",
"rand 0.8.5",
"regex",
"reqwest 0.12.28",
"rustls",
@@ -609,8 +517,6 @@ dependencies = [
"tauri-plugin-global-shortcut",
"tauri-plugin-notification",
"tauri-plugin-opener",
"thiserror 1.0.69",
"tokio",
"url",
"webkit2gtk",
"which",
@@ -1283,16 +1189,6 @@ dependencies = [
"percent-encoding",
]
[[package]]
name = "fs-err"
version = "3.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "73fde052dbfc920003cfd2c8e2c6e6d4cc7c1091538c3a24226cec0665ab08c0"
dependencies = [
"autocfg",
"tokio",
]
[[package]]
name = "fs_extra"
version = "1.3.0"
@@ -1872,12 +1768,6 @@ version = "1.10.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87"
[[package]]
name = "httpdate"
version = "1.0.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9"
[[package]]
name = "hyper"
version = "1.8.1"
@@ -1892,7 +1782,6 @@ dependencies = [
"http",
"http-body",
"httparse",
"httpdate",
"itoa",
"pin-project-lite",
"pin-utils",
@@ -2441,12 +2330,6 @@ version = "0.1.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2532096657941c2fea9c289d370a250971c689d4f143798ff67113ec042024a5"
[[package]]
name = "matchit"
version = "0.7.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0e7465ac9959cc2b1404e8e2367b43684a6d13790fe23056cc8c6c5a6b7bcb94"
[[package]]
name = "memchr"
version = "2.8.0"
@@ -3670,15 +3553,6 @@ dependencies = [
"zeroize",
]
[[package]]
name = "rustls-pemfile"
version = "2.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dce314e5fee3f39953d46bb63bb8a46d40c2f8fb7cc5a3b6cab2bde9721d6e50"
dependencies = [
"rustls-pki-types",
]
[[package]]
name = "rustls-pki-types"
version = "1.14.0"
@@ -3892,17 +3766,6 @@ dependencies = [
"zmij",
]
[[package]]
name = "serde_path_to_error"
version = "0.1.20"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457"
dependencies = [
"itoa",
"serde",
"serde_core",
]
[[package]]
name = "serde_repr"
version = "0.1.20"
@@ -4812,21 +4675,9 @@ dependencies = [
"mio",
"pin-project-lite",
"socket2",
"tokio-macros",
"windows-sys 0.61.2",
]
[[package]]
name = "tokio-macros"
version = "2.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5c55a2eff8b69ce66c84f85e1da1c233edc36ceb85a2058d11b0d6a3c7e7569c"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.117",
]
[[package]]
name = "tokio-rustls"
version = "0.26.4"
@@ -4968,7 +4819,6 @@ dependencies = [
"tokio",
"tower-layer",
"tower-service",
"tracing",
]
[[package]]
@@ -5007,7 +4857,6 @@ version = "0.1.44"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100"
dependencies = [
"log",
"pin-project-lite",
"tracing-attributes",
"tracing-core",

View File

@@ -14,7 +14,6 @@
"build": "tauri build"
},
"devDependencies": {
"@tauri-apps/cli": "^2.9.4",
"@tauri-apps/cli-darwin-arm64": "^2.9.4"
"@tauri-apps/cli": "^2.9.4"
}
}

View File

@@ -37,6 +37,12 @@ const braceExpansionPath = path.join(
"package.json",
)
const serverBuildDependencyPaths = [
path.join(serverRoot, "node_modules", "typescript", "package.json"),
path.join(serverRoot, "node_modules", "@types", "node-forge", "package.json"),
path.join(serverRoot, "node_modules", "@types", "yauzl", "package.json"),
]
const viteBinPath = path.join(uiRoot, "node_modules", ".bin", "vite")
async function ensureMonacoAssets() {
@@ -98,7 +104,7 @@ function syncServerUiBundle() {
}
function ensureServerDevDependencies() {
if (fs.existsSync(braceExpansionPath)) {
if (serverBuildDependencyPaths.every((filePath) => fs.existsSync(filePath))) {
return
}
@@ -142,6 +148,7 @@ function ensureRollupPlatformBinary() {
"linux-arm64": "@rollup/rollup-linux-arm64-gnu",
"darwin-arm64": "@rollup/rollup-darwin-arm64",
"darwin-x64": "@rollup/rollup-darwin-x64",
"win32-arm64": "@rollup/rollup-win32-arm64-msvc",
"win32-x64": "@rollup/rollup-win32-x64-msvc",
}

View File

@@ -12,20 +12,12 @@ tauri = { version = "2.5.2", features = [ "devtools"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
serde_yaml = "0.9"
axum = "0.7"
axum-server = { version = "0.7", features = ["tls-rustls"] }
base64 = "0.22"
bytes = "1"
futures-util = "0.3"
rustls = { version = "0.23", features = ["ring"] }
reqwest = { version = "0.12", default-features = false, features = ["http2", "charset", "json", "stream", "rustls-tls"] }
rand = "0.8"
regex = "1"
once_cell = "1"
parking_lot = "0.12"
thiserror = "1"
anyhow = "1"
tokio = { version = "1", features = ["macros", "rt-multi-thread", "net", "sync"] }
which = "4"
libc = "0.2"
keepawake = "0.6"

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 74 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 322 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.8 KiB

View File

@@ -0,0 +1,9 @@
[Desktop Entry]
Categories=
Exec=codenomad-tauri
StartupWMClass=codenomad-tauri
Icon=codenomad-tauri
Name=CodeNomad
NoDisplay=true
Terminal=false
Type=Application

View File

@@ -152,6 +152,10 @@ fn trusted_marker_value(cert_der: &[u8]) -> String {
cert_der.iter().map(|byte| format!("{byte:02x}")).collect()
}
fn trusted_marker_file_suffix(cert_der: &[u8]) -> String {
trusted_marker_value(cert_der).chars().take(16).collect()
}
fn has_matching_trusted_marker(cert_der: &[u8]) -> bool {
trusted_marker_path()
.ok()
@@ -170,8 +174,11 @@ fn write_trusted_marker(cert_der: &[u8]) -> Result<(), String> {
.map_err(|e| format!("Failed to write trust marker: {e}"))
}
/// Adds the DER-encoded CA certificate to the Windows `CurrentUser\Root` store.
/// This will show a one-time Windows security confirmation dialog when needed.
#[cfg(windows)]
pub fn needs_trust_in_store(cert_der: &[u8]) -> Result<bool, String> {
Ok(!windows_cert_is_trusted(cert_der)?)
}
#[cfg(windows)]
pub fn trust_cert_in_store(cert_der: &[u8]) -> Result<(), String> {
use windows_sys::Win32::Security::Cryptography::{
@@ -179,7 +186,7 @@ pub fn trust_cert_in_store(cert_der: &[u8]) -> Result<(), String> {
CERT_STORE_ADD_REPLACE_EXISTING, PKCS_7_ASN_ENCODING, X509_ASN_ENCODING,
};
if has_matching_trusted_marker(cert_der) {
if !needs_trust_in_store(cert_der)? {
return Ok(());
}
@@ -215,7 +222,201 @@ pub fn trust_cert_in_store(cert_der: &[u8]) -> Result<(), String> {
Ok(())
}
#[cfg(not(windows))]
#[cfg(target_os = "macos")]
pub fn needs_trust_in_store(cert_der: &[u8]) -> Result<bool, String> {
Ok(!(has_matching_trusted_marker(cert_der) && macos_cert_is_trusted(cert_der)?))
}
#[cfg(target_os = "macos")]
pub fn trust_cert_in_store(cert_der: &[u8]) -> Result<(), String> {
use std::process::Command;
if !needs_trust_in_store(cert_der)? {
return Ok(());
}
let temp_path = env::temp_dir().join(format!(
"codenomad-server-ca-{}.cer",
trusted_marker_file_suffix(cert_der)
));
fs::write(&temp_path, cert_der)
.map_err(|e| format!("Failed to write temporary certificate {}: {e}", temp_path.display()))?;
let keychain_path = resolve_macos_user_keychain()?;
let mut command = Command::new("/usr/bin/security");
command.args(["add-trusted-cert", "-r", "trustRoot", "-k"]);
command.arg(&keychain_path);
let output = command.arg(&temp_path).output().map_err(|e| {
format!(
"Failed to launch macOS security tool to trust the local CA certificate: {e}"
)
})?;
let _ = fs::remove_file(&temp_path);
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
let detail = if stderr.is_empty() {
format!("security exited with status {}", output.status)
} else {
stderr
};
return Err(format!(
"Failed to add the local CodeNomad CA certificate to the macOS trust settings: {detail}"
));
}
if !macos_cert_is_trusted(cert_der)? {
return Err(format!(
"Added the local CodeNomad CA certificate to {} but could not verify that macOS trusts it",
keychain_path.display()
));
}
write_trusted_marker(cert_der)?;
Ok(())
}
#[cfg(windows)]
fn windows_cert_is_trusted(cert_der: &[u8]) -> Result<bool, String> {
use windows_sys::Win32::Security::Cryptography::{
CertCloseStore, CertEnumCertificatesInStore, CertOpenSystemStoreW,
};
let store_name: Vec<u16> = "Root\0".encode_utf16().collect();
unsafe {
let store = CertOpenSystemStoreW(0, store_name.as_ptr());
if store.is_null() {
return Err("Failed to open CurrentUser\\Root certificate store".into());
}
let mut context = CertEnumCertificatesInStore(store, std::ptr::null());
while !context.is_null() {
let encoded = std::slice::from_raw_parts(
(*context).pbCertEncoded,
(*context).cbCertEncoded as usize,
);
if encoded == cert_der {
CertCloseStore(store, 0);
return Ok(true);
}
context = CertEnumCertificatesInStore(store, context);
}
CertCloseStore(store, 0);
Ok(false)
}
}
#[cfg(target_os = "macos")]
fn resolve_macos_user_keychain() -> Result<PathBuf, String> {
let output = std::process::Command::new("/usr/bin/security")
.args(["default-keychain", "-d", "user"])
.output()
.map_err(|e| format!("Failed to resolve macOS default user keychain: {e}"))?;
if output.status.success() {
let stdout = String::from_utf8_lossy(&output.stdout);
let trimmed = stdout.trim().trim_matches('"');
if !trimmed.is_empty() {
return Ok(PathBuf::from(trimmed));
}
}
let home = dirs::home_dir().or_else(|| env::var("HOME").ok().map(PathBuf::from));
let home = home.ok_or_else(|| "Cannot determine home directory for macOS keychain lookup".to_string())?;
Ok(home.join("Library/Keychains/login.keychain-db"))
}
#[cfg(target_os = "macos")]
fn macos_cert_is_trusted(cert_der: &[u8]) -> Result<bool, String> {
use std::process::Command;
let temp_path = env::temp_dir().join(format!(
"codenomad-server-ca-verify-{}.cer",
trusted_marker_file_suffix(cert_der)
));
fs::write(&temp_path, cert_der)
.map_err(|e| format!("Failed to write temporary certificate {}: {e}", temp_path.display()))?;
let keychain_path = resolve_macos_user_keychain()?;
let fingerprint = macos_cert_sha256(&temp_path)?;
let find_output = Command::new("/usr/bin/security")
.args(["find-certificate", "-a", "-Z", "-c", "CodeNomad Local CA"])
.arg(&keychain_path)
.output()
.map_err(|e| format!("Failed to query macOS keychain certificates: {e}"))?;
if !find_output.status.success() {
let _ = fs::remove_file(&temp_path);
let stderr = String::from_utf8_lossy(&find_output.stderr).trim().to_string();
let detail = if stderr.is_empty() {
format!("security exited with status {}", find_output.status)
} else {
stderr
};
return Err(format!(
"Failed to inspect the macOS keychain for the local CodeNomad CA certificate: {detail}"
));
}
let stdout = String::from_utf8_lossy(&find_output.stdout);
if !stdout.to_ascii_uppercase().contains(&fingerprint) {
let _ = fs::remove_file(&temp_path);
return Ok(false);
}
let verify_output = Command::new("/usr/bin/security")
.args(["verify-cert", "-q", "-L", "-l", "-p", "basic", "-c"])
.arg(&temp_path)
.args(["-k"])
.arg(&keychain_path)
.output()
.map_err(|e| format!("Failed to verify macOS trust for the local CodeNomad CA certificate: {e}"))?;
let _ = fs::remove_file(&temp_path);
Ok(verify_output.status.success())
}
#[cfg(target_os = "macos")]
fn macos_cert_sha256(cert_path: &Path) -> Result<String, String> {
let output = std::process::Command::new("/usr/bin/shasum")
.args(["-a", "256"])
.arg(cert_path)
.output()
.map_err(|e| format!("Failed to compute SHA-256 for {}: {e}", cert_path.display()))?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
let detail = if stderr.is_empty() {
format!("shasum exited with status {}", output.status)
} else {
stderr
};
return Err(format!(
"Failed to compute SHA-256 for {}: {detail}",
cert_path.display()
));
}
let stdout = String::from_utf8_lossy(&output.stdout);
let hash = stdout
.split_whitespace()
.next()
.ok_or_else(|| format!("Failed to parse SHA-256 output for {}", cert_path.display()))?;
Ok(hash.to_ascii_uppercase())
}
#[cfg(all(not(windows), not(target_os = "macos")))]
pub fn needs_trust_in_store(_cert_der: &[u8]) -> Result<bool, String> {
Ok(false)
}
#[cfg(all(not(windows), not(target_os = "macos")))]
pub fn trust_cert_in_store(_cert_der: &[u8]) -> Result<(), String> {
// Non-Windows platforms use native webview-specific handling instead of OS trust-store writes.
Ok(())

View File

@@ -38,6 +38,7 @@ use windows_sys::Win32::System::JobObjects::{
#[cfg(windows)]
const CREATE_NO_WINDOW: u32 = 0x08000000;
const MISSING_NODE_PREFIX: &str = "CODENOMAD_MISSING_NODE:";
#[cfg(windows)]
#[derive(Debug)]
@@ -630,6 +631,13 @@ impl CliProcessManager {
let use_user_shell = supports_user_shell();
if !use_user_shell && which::which(&resolution.node_binary).is_err() {
return Err(anyhow::anyhow!(
"Node binary '{}' not found. CodeNomad desktop currently requires Node.js installed on the system, or set NODE_BINARY to a valid runtime path.",
resolution.node_binary
));
}
let command_info = if use_user_shell {
log_line("spawning via user shell");
ShellCommandType::UserShell(build_shell_command_string(&resolution, &args)?)
@@ -641,14 +649,6 @@ impl CliProcessManager {
})
};
if !use_user_shell {
if which::which(&resolution.node_binary).is_err() {
return Err(anyhow::anyhow!(
"Node binary not found. Make sure Node.js is installed."
));
}
}
let child = match &command_info {
ShellCommandType::UserShell(cmd) => {
log_line(&format!("spawn command: {} {:?}", cmd.shell, cmd.args));
@@ -920,6 +920,17 @@ impl CliProcessManager {
continue;
}
if let Some(node_binary) = line.strip_prefix(MISSING_NODE_PREFIX) {
let mut locked = status.lock();
if locked.error.is_none() {
locked.error = Some(format!(
"Node binary '{}' not found in the desktop shell environment. CodeNomad desktop currently requires Node.js installed on the system, or set NODE_BINARY to a valid runtime path.",
node_binary.trim()
));
}
continue;
}
if let Some(url) = local_url_regex
.as_ref()
.and_then(|re| re.captures(line).and_then(|c| c.get(1)))
@@ -1083,7 +1094,8 @@ impl CliEntry {
];
if dev {
// Dev: plain HTTP + Vite dev server proxy.
// Dev: keep loopback HTTP for the Vite proxy, but also enable HTTPS so
// remote proxy sessions can still spin up secure local windows.
let ui_dev_server = std::env::var("VITE_DEV_SERVER_URL")
.ok()
.filter(|value| !value.trim().is_empty())
@@ -1100,7 +1112,7 @@ impl CliEntry {
.unwrap_or_else(|| "info".to_string());
args.push("--https".to_string());
args.push("false".to_string());
args.push("true".to_string());
args.push("--http".to_string());
args.push("true".to_string());
args.push("--http-port".to_string());
@@ -1248,7 +1260,13 @@ fn build_shell_command_string(
for arg in entry.runner_args(cli_args) {
quoted.push(shell_escape(&arg));
}
let command = format!("ELECTRON_RUN_AS_NODE=1 exec {}", quoted.join(" "));
let command = format!(
"if command -v {} >/dev/null 2>&1; then ELECTRON_RUN_AS_NODE=1 exec {}; else printf '%s%s\\n' '{}' {} >&2; exit 127; fi",
shell_escape(&entry.node_binary),
quoted.join(" "),
MISSING_NODE_PREFIX,
shell_escape(&entry.node_binary),
);
let args = build_shell_args(&shell, &command);
log_line(&format!("user shell command: {} {:?}", shell, args));
Ok(ShellCommand { shell, args })
@@ -1288,8 +1306,11 @@ fn build_shell_args(shell: &str, command: &str) -> Vec<String> {
.unwrap_or("")
.to_lowercase();
let _ = shell_name;
vec!["-l".into(), "-c".into(), command.into()]
if shell_name.contains("zsh") || shell_name.contains("bash") {
vec!["-i".into(), "-l".into(), "-c".into(), command.into()]
} else {
vec!["-l".into(), "-c".into(), command.into()]
}
}
fn first_existing(paths: Vec<Option<PathBuf>>) -> Option<String> {

View File

@@ -3,8 +3,8 @@ use tauri::{AppHandle, Manager, WebviewWindow};
use url::Url;
use webkit2gtk::{WebContextExt, WebView, WebViewExt};
pub fn should_bootstrap_tls_navigation(target_url: &Url, skip_tls_verify: bool) -> bool {
skip_tls_verify && target_url.scheme() == "https"
pub fn should_bootstrap_tls_navigation(target_url: &Url, allow_tls_certificate: bool) -> bool {
allow_tls_certificate && target_url.scheme() == "https"
}
pub fn ensure_remote_window_tls_handler(

View File

@@ -20,6 +20,7 @@ use tauri::webview::Webview;
use tauri::{
AppHandle, Emitter, Manager, Runtime, WebviewUrl, WebviewWindowBuilder, WindowEvent, Wry,
};
use tauri_plugin_dialog::{DialogExt, MessageDialogButtons, MessageDialogKind};
use tauri_plugin_global_shortcut::{
Code as ShortcutCode, GlobalShortcutExt, Shortcut, ShortcutState,
};
@@ -49,6 +50,7 @@ pub struct AppState {
pub wake_lock: Mutex<Option<KeepAwake>>,
pub zoom_level: Mutex<f64>,
pub remote_origins: Mutex<HashMap<String, String>>,
pub remote_proxy_sessions: Mutex<HashMap<String, String>>,
pub remote_skip_tls_verify: Mutex<HashMap<String, bool>>,
pub remote_tls_handlers: Mutex<HashSet<String>>,
}
@@ -60,10 +62,86 @@ struct RemoteWindowPayload {
name: String,
base_url: String,
entry_url: Option<String>,
proxy_session_id: Option<String>,
#[allow(dead_code)]
skip_tls_verify: bool,
}
fn schedule_remote_proxy_session_cleanup(app: AppHandle, session_id: String) {
tauri::async_runtime::spawn(async move {
if let Err(err) = cleanup_remote_proxy_session(&app, &session_id).await {
eprintln!(
"[tauri] failed to clean up remote proxy session {}: {}",
session_id, err
);
}
});
}
async fn confirm_local_certificate_install(app: &AppHandle) -> Result<bool, String> {
let (sender, receiver) = std::sync::mpsc::sync_channel(1);
let mut dialog = app
.dialog()
.message(
"CodeNomad needs to install a local certificate to open self-signed HTTPS remote windows. This certificate is only used for local desktop proxy traffic on your machine. Your operating system may show a second certificate prompt after this.",
)
.title("Install Local Certificate")
.kind(MessageDialogKind::Warning)
.buttons(MessageDialogButtons::OkCancelCustom(
"Continue".into(),
"Cancel".into(),
));
if let Some(window) = app.get_webview_window("main") {
dialog = dialog.parent(&window);
}
dialog.show(move |accepted| {
let _ = sender.send(accepted);
});
tauri::async_runtime::spawn_blocking(move || receiver.recv().unwrap_or(false))
.await
.map_err(|err| err.to_string())
}
async fn cleanup_remote_proxy_session(app: &AppHandle, session_id: &str) -> Result<(), String> {
let status = app.state::<AppState>().manager.status();
let Some(base_url) = status.url else {
return Ok(());
};
let mut cleanup_url = Url::parse(&base_url).map_err(|err| err.to_string())?;
cleanup_url.set_path(&format!("/api/remote-proxy/sessions/{session_id}"));
cleanup_url.set_query(None);
cleanup_url.set_fragment(None);
let client = if cleanup_url.scheme() == "https" {
let local_cert = cert_manager::ensure_local_cert()?;
let ca_cert = reqwest::Certificate::from_der(&local_cert.ca_cert_der)
.map_err(|err| err.to_string())?;
reqwest::Client::builder()
.add_root_certificate(ca_cert)
.build()
.map_err(|err| err.to_string())?
} else {
reqwest::Client::new()
};
let response = client
.delete(cleanup_url.as_str())
.send()
.await
.map_err(|err| err.to_string())?;
if response.status().is_success() || response.status() == reqwest::StatusCode::NOT_FOUND {
return Ok(());
}
Err(format!("unexpected status {}", response.status()))
}
#[derive(Debug, Default, Deserialize)]
#[serde(default, rename_all = "camelCase")]
struct WakeLockConfig {
@@ -193,6 +271,9 @@ async fn open_remote_window_impl(
let window_url = parsed.clone();
let allow_linux_tls_certificate =
parsed.scheme() == "https" && (payload.proxy_session_id.is_some() || payload.skip_tls_verify);
app.state::<AppState>()
.remote_origins
.lock()
@@ -202,7 +283,25 @@ async fn open_remote_window_impl(
.remote_skip_tls_verify
.lock()
.map_err(|err| err.to_string())?
.insert(label.clone(), parsed.scheme() == "https");
.insert(label.clone(), allow_linux_tls_certificate);
let replaced_session = {
let state = app.state::<AppState>();
let mut sessions = state
.remote_proxy_sessions
.lock()
.map_err(|err| err.to_string())?;
match payload.proxy_session_id.clone() {
Some(session_id) => sessions.insert(label.clone(), session_id),
None => sessions.remove(&label),
}
};
if let Some(previous) = replaced_session {
if payload.proxy_session_id.as_deref() != Some(previous.as_str()) {
schedule_remote_proxy_session_cleanup(app.clone(), previous);
}
}
if let Some(existing) = app.get_webview_window(&label) {
#[cfg(target_os = "linux")]
@@ -217,8 +316,10 @@ async fn open_remote_window_impl(
}
#[cfg(target_os = "linux")]
let initial_url = if linux_tls::should_bootstrap_tls_navigation(&window_url, payload.skip_tls_verify)
{
let initial_url = if linux_tls::should_bootstrap_tls_navigation(
&window_url,
allow_linux_tls_certificate,
) {
Url::parse("about:blank").map_err(|err| err.to_string())?
} else {
window_url.clone()
@@ -249,6 +350,11 @@ async fn open_remote_window_impl(
if let Ok(mut origins) = app_handle.state::<AppState>().remote_origins.lock() {
origins.remove(&label_for_cleanup);
}
if let Ok(mut sessions) = app_handle.state::<AppState>().remote_proxy_sessions.lock() {
if let Some(session_id) = sessions.remove(&label_for_cleanup) {
schedule_remote_proxy_session_cleanup(app_handle.clone(), session_id);
}
}
if let Ok(mut values) = app_handle.state::<AppState>().remote_skip_tls_verify.lock() {
values.remove(&label_for_cleanup);
}
@@ -267,12 +373,23 @@ async fn open_remote_window(app: AppHandle, payload: RemoteWindowPayload) -> Res
{
let entry_url = payload.entry_url.as_deref().unwrap_or(payload.base_url.as_str());
let parsed = Url::parse(entry_url).map_err(|err| err.to_string())?;
if parsed.scheme() == "https" {
if payload.proxy_session_id.is_some() && parsed.scheme() == "https" {
let local_cert = cert_manager::ensure_local_cert().map_err(|err| {
format!(
"Failed to load the local HTTPS certificate for the remote proxy window: {err}"
)
})?;
if cert_manager::needs_trust_in_store(&local_cert.ca_cert_der).map_err(|err| {
format!("Failed to inspect the local CodeNomad certificate trust state: {err}")
})? {
let accepted = confirm_local_certificate_install(&app).await?;
if !accepted {
return Err(
"CodeNomad needs the local certificate to be trusted before it can open self-signed HTTPS remote windows."
.to_string(),
);
}
}
if let Err(err) = cert_manager::trust_cert_in_store(&local_cert.ca_cert_der) {
return Err(format!(
"Failed to trust the local CodeNomad CA certificate. Accept the certificate installation prompt and try again: {err}"
@@ -440,6 +557,7 @@ fn main() {
wake_lock: Mutex::new(None),
zoom_level: Mutex::new(DEFAULT_ZOOM_LEVEL),
remote_origins: Mutex::new(HashMap::new()),
remote_proxy_sessions: Mutex::new(HashMap::new()),
remote_skip_tls_verify: Mutex::new(HashMap::new()),
remote_tls_handlers: Mutex::new(HashSet::new()),
})

View File

@@ -1,460 +0,0 @@
use axum::body::Body;
use axum::extract::{Request, State};
use axum::http::{HeaderMap, HeaderName, HeaderValue, StatusCode, Uri};
use axum::response::Response;
use axum::routing::any;
use axum::Router;
use axum_server::tls_rustls::RustlsConfig;
use futures_util::TryStreamExt;
use rand::RngCore;
use reqwest::redirect::Policy;
use reqwest::Client;
use std::collections::HashSet;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
use url::Url;
const PROXY_TOKEN_QUERY: &str = "proxy_token";
#[derive(Clone)]
struct ProxyState {
client: Client,
target_base_url: Url,
local_base_url: Url,
session_token: String,
session_activated: Arc<AtomicBool>,
}
/// TLS configuration for the local HTTPS proxy.
pub struct ProxyTlsConfig {
pub cert_pem: String,
pub key_pem: String,
}
pub struct RemoteProxyHandle {
local_base_url: Url,
entry_url: Url,
target_base_url: Url,
skip_tls_verify: bool,
server_handle: axum_server::Handle,
}
impl RemoteProxyHandle {
pub fn local_base_url(&self) -> &Url {
&self.local_base_url
}
pub fn entry_url(&self) -> &Url {
&self.entry_url
}
pub fn matches(&self, target_base_url: &Url, skip_tls_verify: bool) -> bool {
self.target_base_url == *target_base_url && self.skip_tls_verify == skip_tls_verify
}
pub fn shutdown(&self) {
self.server_handle.shutdown();
}
}
impl Drop for RemoteProxyHandle {
fn drop(&mut self) {
self.shutdown();
}
}
pub async fn start_remote_proxy(
target_base_url: Url,
skip_tls_verify: bool,
tls_config: Option<ProxyTlsConfig>,
) -> Result<RemoteProxyHandle, String> {
let client = Client::builder()
.redirect(Policy::none())
.danger_accept_invalid_certs(skip_tls_verify)
.build()
.map_err(|err| err.to_string())?;
// Pre-bind a std TcpListener on port 0 to discover the actual port
let std_listener = std::net::TcpListener::bind("127.0.0.1:0")
.map_err(|err| err.to_string())?;
let address = std_listener.local_addr().map_err(|err| err.to_string())?;
let scheme = if tls_config.is_some() { "https" } else { "http" };
let local_base_url =
Url::parse(&format!("{scheme}://{address}")).map_err(|err| err.to_string())?;
let session_token = generate_session_token();
let mut entry_url = local_base_url.clone();
entry_url.set_path(target_base_url.path());
entry_url.set_query(Some(&format!("{PROXY_TOKEN_QUERY}={session_token}")));
let state = Arc::new(ProxyState {
client,
target_base_url: target_base_url.clone(),
local_base_url: local_base_url.clone(),
session_token,
session_activated: Arc::new(AtomicBool::new(false)),
});
let app = Router::new()
.route("/*path", any(proxy_request))
.route("/", any(proxy_request))
.with_state(state);
let server_handle = axum_server::Handle::new();
let handle_clone = server_handle.clone();
if let Some(tls) = tls_config {
let rustls_config =
RustlsConfig::from_pem(tls.cert_pem.into_bytes(), tls.key_pem.into_bytes())
.await
.map_err(|err| format!("Failed to build RustlsConfig: {err}"))?;
tauri::async_runtime::spawn(async move {
let server = axum_server::from_tcp_rustls(std_listener, rustls_config)
.handle(handle_clone)
.serve(app.into_make_service());
if let Err(err) = server.await {
eprintln!("[tauri] remote proxy (HTTPS) stopped with error: {err}");
}
});
} else {
tauri::async_runtime::spawn(async move {
let server = axum_server::from_tcp(std_listener)
.handle(handle_clone)
.serve(app.into_make_service());
if let Err(err) = server.await {
eprintln!("[tauri] remote proxy (HTTP) stopped with error: {err}");
}
});
}
Ok(RemoteProxyHandle {
local_base_url,
entry_url,
target_base_url,
skip_tls_verify,
server_handle,
})
}
async fn proxy_request(
State(state): State<Arc<ProxyState>>,
request: Request,
) -> Result<Response<Body>, StatusCode> {
if !state.session_activated.load(Ordering::SeqCst) {
if request_bootstraps_session(&request, &state.session_token) {
state.session_activated.store(true, Ordering::SeqCst);
return Ok(build_bootstrap_response(request.uri())?);
}
return Err(StatusCode::FORBIDDEN);
}
let upstream_url = build_upstream_url(&state.target_base_url, request.uri())
.map_err(|_| StatusCode::BAD_REQUEST)?;
let mut builder = state
.client
.request(request.method().clone(), upstream_url.clone());
builder = builder.headers(filter_request_headers(
request.headers(),
&state.target_base_url,
)?);
let body = axum::body::to_bytes(request.into_body(), usize::MAX)
.await
.map_err(|_| StatusCode::BAD_REQUEST)?;
if !body.is_empty() {
builder = builder.body(body);
}
let upstream = builder.send().await.map_err(map_upstream_error)?;
let status = upstream.status();
let headers = rewrite_response_headers(
upstream.headers(),
&state.target_base_url,
&state.local_base_url,
)?;
let stream = upstream
.bytes_stream()
.map_err(|err| std::io::Error::other(err.to_string()));
let mut response = Response::new(Body::from_stream(stream));
*response.status_mut() = status;
*response.headers_mut() = headers;
Ok(response)
}
fn build_upstream_url(base_url: &Url, uri: &Uri) -> Result<Url, url::ParseError> {
let mut url = base_url.clone();
url.set_path(&rewrite_request_path(base_url, uri.path()));
url.set_query(strip_proxy_token_query(uri.query()).as_deref());
Ok(url)
}
fn rewrite_request_path(base_url: &Url, request_path: &str) -> String {
let base_path = normalized_base_path(base_url);
if base_path == "/" {
return request_path.to_string();
}
if request_path == "/" {
return base_path.to_string();
}
if path_has_base_prefix(base_path, request_path) {
return request_path.to_string();
}
format!("{base_path}{request_path}")
}
fn normalized_base_path(base_url: &Url) -> &str {
let path = base_url.path();
if path.is_empty() { "/" } else { path }
}
fn path_has_base_prefix(base_path: &str, request_path: &str) -> bool {
request_path == base_path
|| request_path
.strip_prefix(base_path)
.is_some_and(|suffix| suffix.starts_with('/'))
}
fn generate_session_token() -> String {
let mut bytes = [0_u8; 16];
rand::thread_rng().fill_bytes(&mut bytes);
bytes.iter().map(|byte| format!("{byte:02x}")).collect()
}
fn request_bootstraps_session(request: &Request, session_token: &str) -> bool {
request.uri().query().is_some_and(|query| {
url::form_urlencoded::parse(query.as_bytes())
.any(|(name, value)| name == PROXY_TOKEN_QUERY && value == session_token)
})
}
fn build_bootstrap_response(uri: &Uri) -> Result<Response<Body>, StatusCode> {
let redirect_target = sanitized_request_target(uri);
Response::builder()
.status(StatusCode::FOUND)
.header(axum::http::header::LOCATION, redirect_target)
.body(Body::empty())
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)
}
fn sanitized_request_target(uri: &Uri) -> String {
let path = if uri.path().is_empty() { "/" } else { uri.path() };
match strip_proxy_token_query(uri.query()) {
Some(query) if !query.is_empty() => format!("{path}?{query}"),
_ => path.to_string(),
}
}
fn strip_proxy_token_query(query: Option<&str>) -> Option<String> {
let query = query?;
let filtered: Vec<(std::borrow::Cow<'_, str>, std::borrow::Cow<'_, str>)> =
url::form_urlencoded::parse(query.as_bytes())
.filter(|(name, _)| name != PROXY_TOKEN_QUERY)
.collect();
if filtered.is_empty() {
return None;
}
Some(
url::form_urlencoded::Serializer::new(String::new())
.extend_pairs(filtered)
.finish(),
)
}
fn filter_request_headers(
headers: &HeaderMap,
target_base_url: &Url,
) -> Result<HeaderMap, StatusCode> {
let mut forwarded = HeaderMap::new();
for (name, value) in headers {
if is_hop_by_hop_header(name) || *name == axum::http::header::HOST {
continue;
}
forwarded.append(name.clone(), value.clone());
}
let host = target_base_url.host_str().ok_or(StatusCode::BAD_REQUEST)?;
let host_value = match target_base_url.port() {
Some(port) => format!("{host}:{port}"),
None => host.to_string(),
};
forwarded.insert(
axum::http::header::HOST,
HeaderValue::from_str(&host_value).map_err(|_| StatusCode::BAD_REQUEST)?,
);
let target_origin = target_base_url.origin().ascii_serialization();
if let Ok(origin) = HeaderValue::from_str(&target_origin) {
forwarded.insert(axum::http::header::ORIGIN, origin);
}
if let Some(referer) = rewrite_referer_header(headers, target_base_url) {
forwarded.insert(
axum::http::header::REFERER,
HeaderValue::from_str(&referer).map_err(|_| StatusCode::BAD_REQUEST)?,
);
}
Ok(forwarded)
}
fn rewrite_referer_header(headers: &HeaderMap, target_base_url: &Url) -> Option<String> {
let referer = headers.get(axum::http::header::REFERER)?.to_str().ok()?;
let parsed = Url::parse(referer).ok()?;
let mut rewritten = target_base_url.clone();
rewritten.set_path(&rewrite_request_path(target_base_url, parsed.path()));
rewritten.set_query(parsed.query());
rewritten.set_fragment(parsed.fragment());
Some(rewritten.to_string())
}
fn rewrite_response_headers(
headers: &HeaderMap,
target_base_url: &Url,
local_base_url: &Url,
) -> Result<HeaderMap, StatusCode> {
let mut rewritten = HeaderMap::new();
for (name, value) in headers {
if is_hop_by_hop_header(name) {
continue;
}
if *name == axum::http::header::LOCATION {
if let Ok(location) = value.to_str() {
let next = rewrite_location(location, target_base_url, local_base_url);
rewritten.append(
name.clone(),
HeaderValue::from_str(&next).map_err(|_| StatusCode::BAD_GATEWAY)?,
);
continue;
}
}
if *name == axum::http::header::SET_COOKIE {
if let Ok(cookie) = value.to_str() {
let next = rewrite_set_cookie(cookie);
rewritten.append(
name.clone(),
HeaderValue::from_str(&next).map_err(|_| StatusCode::BAD_GATEWAY)?,
);
continue;
}
}
rewritten.append(name.clone(), value.clone());
}
Ok(rewritten)
}
fn rewrite_set_cookie(cookie: &str) -> String {
cookie
.split(';')
.map(str::trim)
.filter(|part| !part.get(..7).is_some_and(|prefix| prefix.eq_ignore_ascii_case("Domain=")))
.collect::<Vec<_>>()
.join("; ")
}
fn rewrite_location(location: &str, target_base_url: &Url, local_base_url: &Url) -> String {
let Ok(parsed) = target_base_url.join(location) else {
return location.to_string();
};
if parsed.origin() != target_base_url.origin() {
return location.to_string();
}
let mut rewritten = local_base_url.clone();
rewritten.set_path(parsed.path());
rewritten.set_query(parsed.query());
rewritten.set_fragment(parsed.fragment());
rewritten.to_string()
}
fn map_upstream_error(error: reqwest::Error) -> StatusCode {
if error.is_timeout() {
StatusCode::GATEWAY_TIMEOUT
} else if error.is_connect() {
StatusCode::BAD_GATEWAY
} else {
StatusCode::INTERNAL_SERVER_ERROR
}
}
fn is_hop_by_hop_header(name: &HeaderName) -> bool {
static HOP_BY_HOP: std::sync::OnceLock<HashSet<&'static str>> = std::sync::OnceLock::new();
HOP_BY_HOP
.get_or_init(|| {
HashSet::from([
"connection",
"keep-alive",
"proxy-authenticate",
"proxy-authorization",
"te",
"trailer",
"transfer-encoding",
"upgrade",
])
})
.contains(name.as_str())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn build_upstream_url_prefixes_root_relative_requests_under_base_path() {
let base = Url::parse("https://example.com/app").unwrap();
let uri = "/api/auth/status?foo=bar".parse::<Uri>().unwrap();
let upstream = build_upstream_url(&base, &uri).unwrap();
assert_eq!(upstream.as_str(), "https://example.com/app/api/auth/status?foo=bar");
}
#[test]
fn build_upstream_url_keeps_requests_already_under_base_path() {
let base = Url::parse("https://example.com/app").unwrap();
let uri = "/app/api/auth/status?foo=bar".parse::<Uri>().unwrap();
let upstream = build_upstream_url(&base, &uri).unwrap();
assert_eq!(upstream.as_str(), "https://example.com/app/api/auth/status?foo=bar");
}
#[test]
fn build_upstream_url_maps_root_to_base_path() {
let base = Url::parse("https://example.com/app").unwrap();
let uri = "/".parse::<Uri>().unwrap();
let upstream = build_upstream_url(&base, &uri).unwrap();
assert_eq!(upstream.as_str(), "https://example.com/app");
}
#[test]
fn rewrite_referer_header_prefixes_root_relative_path_under_base_path() {
let target = Url::parse("https://example.com/app").unwrap();
let mut headers = HeaderMap::new();
headers.insert(
axum::http::header::REFERER,
HeaderValue::from_static("https://127.0.0.1:3000/api/auth/status?foo=bar"),
);
let referer = rewrite_referer_header(&headers, &target).unwrap();
assert_eq!(referer, "https://example.com/app/api/auth/status?foo=bar");
}
}

View File

@@ -9,6 +9,7 @@
"frontendDist": "resources/ui-loading"
},
"app": {
"enableGTKAppId": true,
"withGlobalTauri": true,
"windows": [
{
@@ -41,6 +42,35 @@
},
"bundle": {
"active": true,
"linux": {
"appimage": {
"files": {
"/usr/share/applications/ai.neuralnomads.codenomad.client.desktop": "icons/linux/ai.neuralnomads.codenomad.client.desktop"
}
},
"deb": {
"files": {
"/usr/share/applications/ai.neuralnomads.codenomad.client.desktop": "icons/linux/ai.neuralnomads.codenomad.client.desktop",
"/usr/share/icons/hicolor/32x32/apps/codenomad-tauri.png": "icons/linux/32x32.png",
"/usr/share/icons/hicolor/48x48/apps/codenomad-tauri.png": "icons/linux/48x48.png",
"/usr/share/icons/hicolor/64x64/apps/codenomad-tauri.png": "icons/linux/64x64.png",
"/usr/share/icons/hicolor/128x128/apps/codenomad-tauri.png": "icons/linux/128x128.png",
"/usr/share/icons/hicolor/256x256/apps/codenomad-tauri.png": "icons/linux/256x256.png",
"/usr/share/icons/hicolor/512x512/apps/codenomad-tauri.png": "icons/linux/512x512.png"
}
},
"rpm": {
"files": {
"/usr/share/applications/ai.neuralnomads.codenomad.client.desktop": "icons/linux/ai.neuralnomads.codenomad.client.desktop",
"/usr/share/icons/hicolor/32x32/apps/codenomad-tauri.png": "icons/linux/32x32.png",
"/usr/share/icons/hicolor/48x48/apps/codenomad-tauri.png": "icons/linux/48x48.png",
"/usr/share/icons/hicolor/64x64/apps/codenomad-tauri.png": "icons/linux/64x64.png",
"/usr/share/icons/hicolor/128x128/apps/codenomad-tauri.png": "icons/linux/128x128.png",
"/usr/share/icons/hicolor/256x256/apps/codenomad-tauri.png": "icons/linux/256x256.png",
"/usr/share/icons/hicolor/512x512/apps/codenomad-tauri.png": "icons/linux/512x512.png"
}
}
},
"resources": [
"resources/server",
"resources/ui-loading"

View File

@@ -19,12 +19,60 @@ interface MonacoDiffViewerProps {
insertContextLabel?: string
}
function getLineCount(value: string): number {
if (!value) return 1
return value.split("\n").length
}
function getDigitCount(value: number): number {
return String(Math.max(1, value)).length
}
function getUnifiedGutterSizing(options: { before: string; after: string }) {
const beforeLineCount = getLineCount(options.before)
const afterLineCount = getLineCount(options.after)
const beforeDigitCount = getDigitCount(beforeLineCount)
const afterDigitCount = getDigitCount(afterLineCount)
const maxDigitCount = Math.max(beforeDigitCount, afterDigitCount)
const extraDigits = Math.max(0, maxDigitCount - 2)
const beforeNumberChars = Math.max(2, beforeDigitCount)
const afterNumberChars = Math.max(2, afterDigitCount)
const fourDigitPenalty = Math.max(0, maxDigitCount - 3)
return {
diffEditorLineNumbersMinChars: Math.max(beforeNumberChars, afterNumberChars),
originalLineNumbersMinChars: beforeNumberChars,
modifiedLineNumbersMinChars: afterNumberChars,
lineDecorationsWidth: 6 + extraDigits * 2 + fourDigitPenalty * 2,
}
}
function getSplitGutterSizing(options: { before: string; after: string }) {
const beforeLineCount = getLineCount(options.before)
const afterLineCount = getLineCount(options.after)
const beforeDigitCount = getDigitCount(beforeLineCount)
const afterDigitCount = getDigitCount(afterLineCount)
const maxDigitCount = Math.max(beforeDigitCount, afterDigitCount)
const extraDigits = Math.max(0, maxDigitCount - 2)
const beforeNumberChars = Math.max(2, beforeDigitCount)
const afterNumberChars = Math.max(2, afterDigitCount)
const fourDigitPenalty = Math.max(0, maxDigitCount - 3)
return {
diffEditorLineNumbersMinChars: Math.max(beforeNumberChars, afterNumberChars),
originalLineNumbersMinChars: beforeNumberChars,
modifiedLineNumbersMinChars: afterNumberChars,
lineDecorationsWidth: 8 + extraDigits * 2 + fourDigitPenalty,
}
}
export function MonacoDiffViewer(props: MonacoDiffViewerProps) {
const { isDark } = useTheme()
let host: HTMLDivElement | undefined
let diffEditor: any = null
let monaco: any = null
let splitLayoutFrame: number | null = null
const [ready, setReady] = createSignal(false)
const [hoveredLine, setHoveredLine] = createSignal<number | null>(null)
const [selectedRange, setSelectedRange] = createSignal<{ startLine: number; endLine: number } | null>(null)
@@ -55,6 +103,44 @@ export function MonacoDiffViewer(props: MonacoDiffViewerProps) {
diffEditor = null
}
const clearSplitLayoutVariables = () => {
if (!host) return
host.style.removeProperty("--split-original-line-number-width")
host.style.removeProperty("--split-original-delete-sign-left")
host.style.removeProperty("--split-original-gutter-width")
}
const syncSplitLayoutVariables = (options: {
viewMode: "split" | "unified"
originalLineNumbersMinChars: number
lineDecorationsWidth: number
}) => {
if (!host) return
if (splitLayoutFrame !== null && typeof window !== "undefined") {
window.cancelAnimationFrame(splitLayoutFrame)
splitLayoutFrame = null
}
if (options.viewMode !== "split" || typeof window === "undefined") {
clearSplitLayoutVariables()
return
}
splitLayoutFrame = window.requestAnimationFrame(() => {
splitLayoutFrame = null
if (!host) return
const originalLineNumbers = host.querySelector<HTMLElement>(".editor.original .line-numbers")
const measuredWidth = originalLineNumbers?.getBoundingClientRect().width ?? 0
const lineNumberWidth =
measuredWidth > 0 ? measuredWidth : Math.max(12, options.originalLineNumbersMinChars * 6)
host.style.setProperty("--split-original-line-number-width", `${lineNumberWidth}px`)
host.style.setProperty("--split-original-delete-sign-left", `${lineNumberWidth}px`)
host.style.setProperty(
"--split-original-gutter-width",
`${lineNumberWidth + options.lineDecorationsWidth}px`,
)
})
}
const getModifiedEditor = () => diffEditor?.getModifiedEditor?.() ?? null
const getActiveInsertRange = () => {
@@ -120,7 +206,7 @@ export function MonacoDiffViewer(props: MonacoDiffViewerProps) {
renderWhitespace: "selection",
fontSize: 13,
wordWrap: props.wordWrap === "on" ? "on" : "off",
glyphMargin: true,
glyphMargin: false,
folding: false,
// Keep enough gutter space so unified diffs don't overlap `+`/`-` markers.
lineNumbersMinChars: 4,
@@ -139,6 +225,11 @@ export function MonacoDiffViewer(props: MonacoDiffViewerProps) {
onCleanup(() => {
cancelled = true
if (splitLayoutFrame !== null && typeof window !== "undefined") {
window.cancelAnimationFrame(splitLayoutFrame)
splitLayoutFrame = null
}
clearSplitLayoutVariables()
setReady(false)
disposeEditor()
})
@@ -149,6 +240,11 @@ export function MonacoDiffViewer(props: MonacoDiffViewerProps) {
monaco.editor.setTheme(isDark() ? "vs-dark" : "vs")
})
createEffect(() => {
if (!host) return
host.dataset.viewMode = props.viewMode === "split" ? "split" : "unified"
})
createEffect(() => {
if (!ready() || !monaco || !diffEditor) return
const modifiedEditor = diffEditor.getModifiedEditor?.()
@@ -222,10 +318,23 @@ export function MonacoDiffViewer(props: MonacoDiffViewerProps) {
const viewMode = props.viewMode === "unified" ? "unified" : "split"
const contextMode = props.contextMode === "collapsed" ? "collapsed" : "expanded"
const wordWrap = props.wordWrap === "on" ? "on" : "off"
const { before, after } = resolvedContent()
const sizing =
viewMode === "unified"
? getUnifiedGutterSizing({ before, after })
: getSplitGutterSizing({ before, after })
const {
diffEditorLineNumbersMinChars,
originalLineNumbersMinChars,
modifiedLineNumbersMinChars,
lineDecorationsWidth,
} = sizing
diffEditor.updateOptions({
renderSideBySide: viewMode === "split",
renderSideBySideInlineBreakpoint: 0,
renderIndicators: true,
lineNumbersMinChars: diffEditorLineNumbersMinChars,
lineDecorationsWidth,
hideUnchangedRegions:
contextMode === "collapsed"
? { enabled: true }
@@ -234,16 +343,30 @@ export function MonacoDiffViewer(props: MonacoDiffViewerProps) {
})
try {
diffEditor.getOriginalEditor?.()?.updateOptions?.({ wordWrap })
diffEditor.getOriginalEditor?.()?.updateOptions?.({
wordWrap,
lineNumbersMinChars: originalLineNumbersMinChars,
lineDecorationsWidth,
})
} catch {
// ignore
}
try {
diffEditor.getModifiedEditor?.()?.updateOptions?.({ wordWrap })
diffEditor.getModifiedEditor?.()?.updateOptions?.({
wordWrap,
lineNumbersMinChars: modifiedLineNumbersMinChars,
lineDecorationsWidth,
})
} catch {
// ignore
}
syncSplitLayoutVariables({
viewMode,
originalLineNumbersMinChars,
lineDecorationsWidth,
})
})
createEffect(() => {

View File

@@ -333,15 +333,23 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
})
if (openWindow) {
const windowUrl =
runtimeEnv.host === "tauri"
? (await serverApi.createRemoteProxySession({
const remoteProxySession =
runtimeEnv.host === "tauri" && profile.skipTlsVerify && profile.baseUrl.startsWith("https://")
? await serverApi.createRemoteProxySession({
baseUrl: profile.baseUrl,
skipTlsVerify: profile.skipTlsVerify,
})).windowUrl
})
: undefined
await openRemoteServerWindow(profile, windowUrl)
try {
await openRemoteServerWindow(profile, remoteProxySession?.windowUrl, remoteProxySession?.sessionId)
} catch (error) {
if (remoteProxySession) {
void serverApi.deleteRemoteProxySession(remoteProxySession.sessionId).catch(() => {})
}
throw error
}
await markRemoteServerConnected(profile.id)
}

View File

@@ -357,7 +357,11 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
const pill = activeSessionStatusPill()
if (!pill) return null
return (
<span class={`status-indicator session-status session-status-list ${pill.className}`} title={pill.title}>
<span
class={`status-indicator session-status session-status-list ${pill.className} notranslate`}
title={pill.title}
translate="no"
>
{pill.showAlertIcon ? <ShieldAlert class="w-3.5 h-3.5" aria-hidden="true" /> : <span class="status-dot" />}
{pill.text}
</span>

View File

@@ -384,6 +384,7 @@ const GitChangesTab: Component<GitChangesTabProps> = (props) => {
onContextModeChange={props.onContextModeChange}
onWordWrapModeChange={props.onWordWrapModeChange}
/>
</>
}
list={{ panel: renderGroupedList, overlay: renderGroupedList }}

View File

@@ -638,18 +638,25 @@ export default function MessageSection(props: MessageSectionProps) {
const autoPinHoldTargetKey = createMemo(() => {
if (!holdLongAssistantRepliesEnabled()) return null
const messageId = lastVisibleMessageId()
return isAssistantTextMessage(messageId) ? messageId : null
return isStreamingAssistantTextMessage(messageId) ? messageId : null
})
function toggleHoldLongAssistantReplies() {
updatePreferences({ holdLongAssistantReplies: !holdLongAssistantRepliesEnabled() })
}
function isAssistantTextMessage(messageId: string | null | undefined) {
function isStreamingAssistantTextMessage(messageId: string | null | undefined) {
if (!messageId) return false
const resolvedStore = store()
const record = resolvedStore.getMessage(messageId)
if (!record || record.role !== "assistant") return false
if (record.status !== "streaming") return false
const info = resolvedStore.getMessageInfo(messageId)
if (!info) return false
const timeInfo = info?.time as { end?: number } | undefined
const isStreaming = timeInfo?.end === undefined || timeInfo.end === 0
if (!isStreaming) return false
const { orderedParts } = buildRecordDisplayData(props.instanceId, record)
return orderedParts.some((part) => {

View File

@@ -581,113 +581,6 @@ export default function PromptInput(props: PromptInputProps) {
autoCapitalize="off"
autocomplete="off"
/>
<div class="prompt-nav-buttons">
<div class="prompt-nav-column prompt-nav-column-left">
<Show when={showVoiceInput()}>
<button
type="button"
class={`prompt-voice-button prompt-nav-voice-button ${voiceInput.isRecording() ? "is-recording" : ""}`}
onPointerDown={(event) => {
event.preventDefault()
beginVoicePress(event)
}}
onPointerUp={(event) => {
event.preventDefault()
endVoicePress()
}}
onPointerCancel={() => endVoicePress()}
onLostPointerCapture={() => endVoicePress()}
onKeyDown={(event) => {
if (event.repeat) return
if (event.key !== " " && event.key !== "Enter") return
event.preventDefault()
beginVoicePress(event)
}}
onKeyUp={(event) => {
if (event.key !== " " && event.key !== "Enter") return
event.preventDefault()
endVoicePress()
}}
onBlur={() => endVoicePress()}
disabled={!voiceInput.isRecording() && (props.disabled || voiceInput.isTranscribing() || !voiceInput.canUseVoiceInput())}
aria-label={voiceInput.buttonTitle()}
title={voiceInput.buttonTitle()}
>
<Show
when={voiceInput.isRecording()}
fallback={
<Show when={voiceInput.isTranscribing()} fallback={<Mic class="h-4 w-4" aria-hidden="true" />}>
<Loader2 class="h-4 w-4 animate-spin" aria-hidden="true" />
</Show>
}
>
<Mic class="h-4 w-4" aria-hidden="true" />
</Show>
</button>
</Show>
<Show when={showConversationToggle()}>
<button
type="button"
class={`prompt-voice-button prompt-nav-voice-button prompt-conversation-button ${conversationModeEnabled() ? "is-active" : ""}`}
onClick={() => toggleConversationMode(props.instanceId)}
disabled={!conversationModeEnabled() && !canToggleConversationMode()}
aria-pressed={conversationModeEnabled()}
aria-label={conversationModeButtonTitle()}
title={conversationModeButtonTitle()}
>
<Volume2 class="h-4 w-4" aria-hidden="true" />
</button>
</Show>
<button
type="button"
class="prompt-clear-button"
onClick={handleClearPrompt}
disabled={!canClearPrompt()}
aria-label={t("promptInput.clear.ariaLabel")}
title={t("promptInput.clear.title")}
>
<X class="h-4 w-4" aria-hidden="true" />
</button>
</div>
<div class="prompt-nav-column prompt-nav-column-right">
<ExpandButton
expandState={expandState}
onToggleExpand={handleExpandToggle}
/>
<Show when={hasHistory()}>
<button
type="button"
class="prompt-history-button"
onClick={() =>
selectPreviousHistory({
force: true,
isPickerOpen: showPicker(),
getTextarea: () => textareaRef,
})
}
disabled={!canHistoryGoPrevious()}
aria-label={t("promptInput.history.previousAriaLabel")}
>
<ArrowBigUp class="h-5 w-5" aria-hidden="true" />
</button>
<button
type="button"
class="prompt-history-button"
onClick={() =>
selectNextHistory({
force: true,
isPickerOpen: showPicker(),
getTextarea: () => textareaRef,
})
}
disabled={!canHistoryGoNext()}
aria-label={t("promptInput.history.nextAriaLabel")}
>
<ArrowBigDown class="h-5 w-5" aria-hidden="true" />
</button>
</Show>
</div>
</div>
<Show when={shouldShowOverlay()}>
<div class={`prompt-input-overlay keyboard-hints ${mode() === "shell" ? "shell-mode" : ""}`}>
<Show
@@ -742,6 +635,116 @@ export default function PromptInput(props: PromptInputProps) {
</div>
<div class="prompt-input-actions">
<div class="prompt-nav-buttons">
<div class="prompt-nav-column prompt-nav-column-left">
<Show when={showVoiceInput()}>
<button
type="button"
class={`prompt-voice-button prompt-nav-voice-button ${voiceInput.isRecording() ? "is-recording" : ""}`}
onPointerDown={(event) => {
event.preventDefault()
beginVoicePress(event)
}}
onPointerUp={(event) => {
event.preventDefault()
endVoicePress()
}}
onPointerCancel={() => endVoicePress()}
onLostPointerCapture={() => endVoicePress()}
onKeyDown={(event) => {
if (event.repeat) return
if (event.key !== " " && event.key !== "Enter") return
event.preventDefault()
beginVoicePress(event)
}}
onKeyUp={(event) => {
if (event.key !== " " && event.key !== "Enter") return
event.preventDefault()
endVoicePress()
}}
onBlur={() => endVoicePress()}
disabled={!voiceInput.isRecording() && (props.disabled || voiceInput.isTranscribing() || !voiceInput.canUseVoiceInput())}
aria-label={voiceInput.buttonTitle()}
title={voiceInput.buttonTitle()}
>
<Show
when={voiceInput.isRecording()}
fallback={
<Show when={voiceInput.isTranscribing()} fallback={<Mic class="h-4 w-4" aria-hidden="true" />}>
<Loader2 class="h-4 w-4 animate-spin" aria-hidden="true" />
</Show>
}
>
<Mic class="h-4 w-4" aria-hidden="true" />
</Show>
</button>
</Show>
<Show when={showConversationToggle()}>
<button
type="button"
class={`prompt-voice-button prompt-nav-voice-button prompt-conversation-button ${conversationModeEnabled() ? "is-active" : ""}`}
onClick={() => toggleConversationMode(props.instanceId)}
disabled={!conversationModeEnabled() && !canToggleConversationMode()}
aria-pressed={conversationModeEnabled()}
aria-label={conversationModeButtonTitle()}
title={conversationModeButtonTitle()}
>
<Volume2 class="h-4 w-4" aria-hidden="true" />
</button>
</Show>
<button
type="button"
class="prompt-clear-button"
onClick={handleClearPrompt}
disabled={!canClearPrompt()}
aria-label={t("promptInput.clear.ariaLabel")}
title={t("promptInput.clear.title")}
>
<X class="h-4 w-4" aria-hidden="true" />
</button>
</div>
<div class="prompt-nav-column prompt-nav-column-right">
<ExpandButton
expandState={expandState}
onToggleExpand={handleExpandToggle}
/>
<Show when={hasHistory()}>
<button
type="button"
class="prompt-history-button"
onClick={() =>
selectPreviousHistory({
force: true,
isPickerOpen: showPicker(),
getTextarea: () => textareaRef,
})
}
disabled={!canHistoryGoPrevious()}
aria-label={t("promptInput.history.previousAriaLabel")}
>
<ArrowBigUp class="h-5 w-5" aria-hidden="true" />
</button>
<button
type="button"
class="prompt-history-button"
onClick={() =>
selectNextHistory({
force: true,
isPickerOpen: showPicker(),
getTextarea: () => textareaRef,
})
}
disabled={!canHistoryGoNext()}
aria-label={t("promptInput.history.nextAriaLabel")}
>
<ArrowBigDown class="h-5 w-5" aria-hidden="true" />
</button>
</Show>
</div>
</div>
</div>
<div class="prompt-input-primary-actions">
<button
type="button"
class="stop-button"

View File

@@ -520,7 +520,11 @@ const SessionList: Component<SessionListProps> = (props) => {
<ChevronDown class={`w-3.5 h-3.5 transition-transform ${rowProps.expanded ? "" : "-rotate-90"}`} />
</span>
</Show>
<span class={`status-indicator session-status session-status-list ${statusClassName()}`} title={statusTooltip()}>
<span
class={`status-indicator session-status session-status-list ${statusClassName()} notranslate`}
title={statusTooltip()}
translate="no"
>
{needsInput() ? <ShieldAlert class="w-3.5 h-3.5" aria-hidden="true" /> : <span class="status-dot" />}
{statusText()}
</span>
@@ -736,7 +740,9 @@ const SessionList: Component<SessionListProps> = (props) => {
<div class="session-list-header p-3 border-b border-base">
{props.headerContent ?? (
<div class="flex items-center justify-between gap-3">
<h3 class="text-sm font-semibold text-primary">{t("sessionList.header.title")}</h3>
<h3 class="text-sm font-semibold text-primary notranslate" translate="no">
{t("sessionList.header.title")}
</h3>
<KeyboardHint
shortcuts={[keyboardRegistry.get("session-prev")!, keyboardRegistry.get("session-next")!].filter(Boolean)}
/>

View File

@@ -3,7 +3,6 @@ import { Virtualizer, type VirtualizerHandle } from "virtua/solid"
const DEFAULT_SCROLL_SENTINEL_MARGIN_PX = 48
const DEFAULT_HOLD_TARGET_TOP_THRESHOLD_PX = 8
const DEFAULT_HOLD_TARGET_TOP_OVERSHOOT_PX = 128
const USER_SCROLL_INTENT_WINDOW_MS = 600
const SCROLL_INTENT_KEYS = new Set(["ArrowUp", "ArrowDown", "PageUp", "PageDown", "Home", "End", " ", "Spacebar"])
@@ -162,8 +161,9 @@ export default function VirtualFollowList<T>(props: VirtualFollowListProps<T>) {
const [showScrollTopButton, setShowScrollTopButton] = createSignal(false)
const [showScrollBottomButton, setShowScrollBottomButton] = createSignal(false)
const [activeKey, setActiveKey] = createSignal<string | null>(null)
const [heldItemCount, setHeldItemCount] = createSignal<number | null>(null)
const effectiveSuspendAutoPinToBottom = () => externalSuspendAutoPinToBottom() || heldItemCount() !== null
const [activeHoldTargetKey, setActiveHoldTargetKey] = createSignal<string | null>(null)
const [didTriggerHoldForCurrentTarget, setDidTriggerHoldForCurrentTarget] = createSignal(false)
const effectiveSuspendAutoPinToBottom = () => externalSuspendAutoPinToBottom() || activeHoldTargetKey() !== null
const scrollButtonsCount = createMemo(() => (showScrollTopButton() ? 1 : 0) + (showScrollBottomButton() ? 1 : 0))
const itemElements = new Map<string, HTMLDivElement>()
@@ -197,6 +197,17 @@ export default function VirtualFollowList<T>(props: VirtualFollowListProps<T>) {
return performance.now() <= userScrollIntentUntil
}
function clearAutoPinHold(options?: { resumeBottom?: boolean }) {
if (activeHoldTargetKey() === null) return
setActiveHoldTargetKey(null)
if (options?.resumeBottom && autoScroll()) {
requestAnimationFrame(() => {
if (!autoScroll() || activeHoldTargetKey() !== null) return
scrollToBottom(false)
})
}
}
function attachScrollIntentListeners(element: HTMLDivElement | undefined) {
if (detachScrollIntentListeners) {
detachScrollIntentListeners()
@@ -258,7 +269,7 @@ export default function VirtualFollowList<T>(props: VirtualFollowListProps<T>) {
// scrollbar). If follow mode stays enabled, the next render notification
// snaps the list straight back to bottom. A real upward viewport move away
// from bottom should always break follow unless a hold target is active.
if (wasPinnedAtBottom && scrolledUp && autoScroll() && !atBottom && heldItemCount() === null) {
if (wasPinnedAtBottom && scrolledUp && autoScroll() && !atBottom && activeHoldTargetKey() === null) {
setAutoScroll(false)
lastObservedPinnedAtBottom = false
return
@@ -266,9 +277,7 @@ export default function VirtualFollowList<T>(props: VirtualFollowListProps<T>) {
// Sync autoScroll state based on scroll position if it was a user scroll
if (hasUserScrollIntent()) {
if (atBottom && heldItemCount() !== null) {
setHeldItemCount(null)
}
clearAutoPinHold()
if (atBottom && !autoScroll()) {
setAutoScroll(true)
} else if (!atBottom && autoScroll()) {
@@ -304,7 +313,6 @@ export default function VirtualFollowList<T>(props: VirtualFollowListProps<T>) {
}
}
updateScrollButtons()
updateAutoPinHold()
props.onScroll?.()
// Find active key (roughly the first visible item)
@@ -336,25 +344,14 @@ export default function VirtualFollowList<T>(props: VirtualFollowListProps<T>) {
function updateAutoPinHold() {
const element = scrollElement()
const itemCount = props.items().length
const heldCount = heldItemCount()
if (!element) return
if (heldCount !== null) {
if (itemCount > heldCount) {
setHeldItemCount(null)
if (autoScroll()) {
requestAnimationFrame(() => {
if (!autoScroll()) return
scrollToBottom(false)
})
}
return
}
const targetKey = holdTargetKey()
const heldKey = activeHoldTargetKey()
if (itemCount < heldCount) {
setHeldItemCount(null)
return
if (heldKey !== null) {
if (targetKey !== heldKey) {
clearAutoPinHold({ resumeBottom: true })
}
return
@@ -362,9 +359,8 @@ export default function VirtualFollowList<T>(props: VirtualFollowListProps<T>) {
if (!autoScroll()) return
if (externalSuspendAutoPinToBottom()) return
const targetKey = holdTargetKey()
if (!targetKey) return
if (didTriggerHoldForCurrentTarget()) return
const itemWrapper = itemElements.get(targetKey)
if (!itemWrapper) return
@@ -375,12 +371,13 @@ export default function VirtualFollowList<T>(props: VirtualFollowListProps<T>) {
const relativeTop = targetRect.top - containerRect.top
const exceedsViewport = targetRect.height > element.clientHeight
if (
exceedsViewport &&
relativeTop <= holdTargetTopThresholdPx() &&
relativeTop >= holdTargetTopThresholdPx() - DEFAULT_HOLD_TARGET_TOP_OVERSHOOT_PX
) {
setHeldItemCount(itemCount)
if (exceedsViewport && relativeTop < 0) {
const alignDelta = relativeTop - holdTargetTopThresholdPx()
if (Math.abs(alignDelta) > 1) {
element.scrollTop = Math.max(0, element.scrollTop + alignDelta)
}
setActiveHoldTargetKey(targetKey)
setDidTriggerHoldForCurrentTarget(true)
}
}
@@ -396,7 +393,7 @@ export default function VirtualFollowList<T>(props: VirtualFollowListProps<T>) {
},
notifyContentRendered: () => {
updateAutoPinHold()
if (heldItemCount() !== null) return
if (activeHoldTargetKey() !== null) return
if (autoScroll() && !effectiveSuspendAutoPinToBottom()) {
scrollToBottom(true)
}
@@ -412,14 +409,23 @@ export default function VirtualFollowList<T>(props: VirtualFollowListProps<T>) {
createEffect(on(() => props.resetKey?.(), () => {
itemElements.clear()
setHeldItemCount(null)
setActiveHoldTargetKey(null)
setDidTriggerHoldForCurrentTarget(false)
lastObservedScrollOffset = 0
lastObservedPinnedAtBottom = false
}))
createEffect(on(holdTargetKey, (nextTargetKey, prevTargetKey) => {
if (nextTargetKey !== prevTargetKey && didTriggerHoldForCurrentTarget()) {
setDidTriggerHoldForCurrentTarget(false)
}
if (activeHoldTargetKey() === null) return
if (nextTargetKey === activeHoldTargetKey()) return
clearAutoPinHold({ resumeBottom: true })
}, { defer: true }))
// Handle autoScroll (Follow) on items change
createEffect(on(() => props.items().length, (len, prevLen) => {
updateAutoPinHold()
if (len > (prevLen ?? 0) && autoScroll() && !effectiveSuspendAutoPinToBottom() && !suppressAutoScrollOnce) {
requestAnimationFrame(() => scrollToBottom(true))
}
@@ -428,16 +434,11 @@ export default function VirtualFollowList<T>(props: VirtualFollowListProps<T>) {
// Handle followToken change
createEffect(on(() => props.followToken?.(), () => {
updateAutoPinHold()
if (autoScroll() && !effectiveSuspendAutoPinToBottom()) {
scrollToBottom(true)
}
}, { defer: true }))
createEffect(on(() => holdTargetKey(), () => {
updateAutoPinHold()
}, { defer: true }))
// Reset state on resetKey change
createEffect(on(() => props.resetKey?.(), (nextKey) => {
if (nextKey === lastResetKey) return
@@ -460,13 +461,6 @@ export default function VirtualFollowList<T>(props: VirtualFollowListProps<T>) {
}
})
createEffect(() => {
if (typeof window === "undefined") return
const handleResize = () => updateAutoPinHold()
window.addEventListener("resize", handleResize)
onCleanup(() => window.removeEventListener("resize", handleResize))
})
return (
<div class="virtual-follow-list-shell" ref={shellElement => {
setShellElement(shellElement)

View File

@@ -26,6 +26,14 @@ type WorktreeOption =
| { kind: "action"; key: "__create__"; label: string }
| { kind: "worktree"; key: string; slug: string; directory: string; raw: WorktreeDescriptor }
type DeleteErrorKind = "localChanges" | "inUse" | "notFound" | "permissionDenied" | "unknown"
type DeleteErrorDetails = {
summary: string
causeLabel: string
nextStep: string
}
function preventSelectPress(event: PointerEvent | MouseEvent) {
// Prevent Select.Item from treating this as a selection.
// We intentionally prevent default to stop Kobalte's internal press handling.
@@ -64,6 +72,57 @@ function relativePath(fromDir: string, toDir: string): string {
return relParts.join("/") || "."
}
function extractDeleteErrorMessage(input: string): string {
const trimmed = (input ?? "").trim()
if (!trimmed) return ""
try {
const parsed = JSON.parse(trimmed) as { error?: unknown }
if (typeof parsed?.error === "string" && parsed.error.trim()) {
return parsed.error.trim()
}
} catch {
// Fall back to the raw string when the backend returned plain text.
}
return trimmed
}
function classifyDeleteError(message: string): DeleteErrorKind {
const normalized = message.toLowerCase()
if (
normalized.includes("modified or untracked files") ||
normalized.includes("contains modified") ||
normalized.includes("contains untracked") ||
normalized.includes("use --force to delete it")
) {
return "localChanges"
}
if (
normalized.includes("in use") ||
normalized.includes("resource busy") ||
normalized.includes("device or resource busy") ||
normalized.includes("ebusy") ||
normalized.includes("file is being used") ||
normalized.includes("process cannot access the file") ||
normalized.includes("directory not empty")
) {
return "inUse"
}
if (normalized.includes("not found") || normalized.includes("no such file") || normalized.includes("cannot find")) {
return "notFound"
}
if (normalized.includes("permission denied") || normalized.includes("access is denied") || normalized.includes("eperm")) {
return "permissionDenied"
}
return "unknown"
}
interface WorktreeSelectorProps {
instanceId: string
sessionId: string
@@ -80,6 +139,7 @@ export default function WorktreeSelector(props: WorktreeSelectorProps) {
const [deleteTarget, setDeleteTarget] = createSignal<WorktreeOption & { kind: "worktree" } | null>(null)
const [forceDelete, setForceDelete] = createSignal(false)
const [isDeleting, setIsDeleting] = createSignal(false)
const [deleteError, setDeleteError] = createSignal<string | null>(null)
const session = createMemo(() => sessions().get(props.instanceId)?.get(props.sessionId))
const isChildSession = createMemo(() => Boolean(session()?.parentId))
@@ -114,10 +174,16 @@ export default function WorktreeSelector(props: WorktreeSelectorProps) {
const openDeleteDialog = (opt: WorktreeOption & { kind: "worktree" }) => {
if (opt.slug === "root") return
setForceDelete(false)
setDeleteError(null)
setDeleteTarget(opt)
setDeleteOpen(true)
}
const closeDeleteDialog = () => {
setDeleteOpen(false)
setDeleteError(null)
}
const repoRoot = createMemo(() => {
const list = getWorktrees(props.instanceId)
return list.find((wt) => wt.slug === "root")?.directory ?? ""
@@ -139,6 +205,89 @@ export default function WorktreeSelector(props: WorktreeSelectorProps) {
}
}
const sanitizeDeleteError = (input: string) => {
let sanitized = (input ?? "").trim()
if (!sanitized) {
return t("instanceShell.worktree.delete.error.fallback")
}
sanitized = sanitized.replace(/[A-Za-z]:[\\/][^\r\n"']+/g, "[path]")
sanitized = sanitized.replace(/\\Users\\[^\\/\r\n]+/gi, "\\Users\\[user]")
sanitized = sanitized.replace(/\/Users\/[^/\r\n]+/g, "/Users/[user]")
sanitized = sanitized.replace(/\/home\/[^/\r\n]+/g, "/home/[user]")
sanitized = sanitized.replace(/([A-Za-z]:[\\/])?Users[\\/][^\\/\r\n]+/gi, "$1Users/[user]")
return sanitized
}
const handleCopyDeleteError = async (mode: "raw" | "sanitized") => {
const raw = deleteError()
if (!raw) return
const text = mode === "sanitized" ? sanitizeDeleteError(raw) : raw
try {
const ok = await copyToClipboard(text)
showToastNotification({
message: ok
? t(mode === "sanitized" ? "instanceShell.worktree.delete.error.copySanitizedSuccess" : "instanceShell.worktree.delete.error.copySuccess")
: t("instanceShell.worktree.delete.error.copyFailure"),
variant: ok ? "success" : "error",
})
} catch (error) {
log.error("Failed to copy delete worktree error", error)
showToastNotification({
message: t("instanceShell.worktree.delete.error.copyFailure"),
variant: "error",
})
}
}
const deleteErrorDetails = createMemo<DeleteErrorDetails | null>(() => {
const raw = deleteError()
if (!raw) return null
const parsed = extractDeleteErrorMessage(raw)
const kind = classifyDeleteError(parsed)
switch (kind) {
case "localChanges":
return {
summary: t("instanceShell.worktree.delete.error.summary.localChanges"),
causeLabel: t("instanceShell.worktree.delete.error.cause.localChanges"),
nextStep: t("instanceShell.worktree.delete.error.nextStep.localChanges"),
}
case "inUse":
return {
summary: t("instanceShell.worktree.delete.error.summary.inUse"),
causeLabel: t("instanceShell.worktree.delete.error.cause.inUse"),
nextStep: t("instanceShell.worktree.delete.error.nextStep.inUse"),
}
case "notFound":
return {
summary: t("instanceShell.worktree.delete.error.summary.notFound"),
causeLabel: t("instanceShell.worktree.delete.error.cause.notFound"),
nextStep: t("instanceShell.worktree.delete.error.nextStep.notFound"),
}
case "permissionDenied":
return {
summary: t("instanceShell.worktree.delete.error.summary.permissionDenied"),
causeLabel: t("instanceShell.worktree.delete.error.cause.permissionDenied"),
nextStep: t("instanceShell.worktree.delete.error.nextStep.permissionDenied"),
}
default:
return {
summary: t("instanceShell.worktree.delete.error.summary.unknown"),
causeLabel: t("instanceShell.worktree.delete.error.cause.unknown"),
nextStep: t("instanceShell.worktree.delete.error.nextStep.unknown"),
}
}
})
const displayDeleteError = createMemo(() => {
const raw = deleteError()
if (!raw) return null
return extractDeleteErrorMessage(raw)
})
const handleChange = async (value: WorktreeOption | null) => {
if (worktreesUnavailable()) return
if (!value) return
@@ -343,22 +492,23 @@ export default function WorktreeSelector(props: WorktreeSelectorProps) {
</Dialog.Portal>
</Dialog>
<Dialog open={deleteOpen()} onOpenChange={(open) => !open && setDeleteOpen(false)}>
<Dialog open={deleteOpen()} onOpenChange={(open) => !open && closeDeleteDialog()}>
<Dialog.Portal>
<Dialog.Overlay class="modal-overlay" />
<div class="fixed inset-0 z-50 flex items-center justify-center p-4">
<Dialog.Content class="modal-surface w-full max-w-md p-6 flex flex-col gap-5">
<div class="fixed inset-0 z-50 flex items-center justify-center p-3 md:p-4">
<Dialog.Content class="modal-surface w-[clamp(640px,45vw,960px)] max-w-[calc(100vw-2rem)] max-h-[calc(100vh-2rem)] overflow-y-auto p-4 flex flex-col gap-3">
<div>
<Dialog.Title class="text-xl font-semibold text-primary">Delete worktree</Dialog.Title>
<Dialog.Description class="text-sm text-secondary mt-2">Removes the git worktree checkout directory for this branch.</Dialog.Description>
<Dialog.Description class="text-sm text-secondary mt-1">Deletes this branch worktree and its local folder.</Dialog.Description>
</div>
<Show when={deleteTarget()}>
{(target) => (
<div class="rounded-lg border border-base bg-surface-secondary p-4">
<p class="text-xs font-medium text-muted uppercase tracking-wide mb-1">Worktree</p>
<p class="text-sm font-mono text-primary break-all">{target().slug}</p>
<p class="text-[11px] text-secondary mt-2 break-all font-mono">{target().directory}</p>
<div class="rounded-lg border border-base bg-surface-secondary px-3 py-2">
<p class="text-sm text-primary">
Worktree <span class="font-semibold font-mono">&quot;{target().slug}&quot;</span>
</p>
<p class="text-[11px] text-secondary break-all font-mono leading-5">{target().directory}</p>
</div>
)}
</Show>
@@ -377,7 +527,7 @@ export default function WorktreeSelector(props: WorktreeSelectorProps) {
<button
type="button"
class="selector-button selector-button-secondary"
onClick={() => setDeleteOpen(false)}
onClick={closeDeleteDialog}
disabled={isDeleting()}
>
Cancel
@@ -389,12 +539,13 @@ export default function WorktreeSelector(props: WorktreeSelectorProps) {
onClick={() => {
const target = deleteTarget()
if (!target) {
setDeleteOpen(false)
closeDeleteDialog()
return
}
void (async () => {
setIsDeleting(true)
setDeleteError(null)
await deleteWorktree(props.instanceId, target.slug, { force: forceDelete() })
await reloadWorktrees(props.instanceId)
await reloadWorktreeMap(props.instanceId)
@@ -403,15 +554,12 @@ export default function WorktreeSelector(props: WorktreeSelectorProps) {
await setWorktreeSlugForParentSession(props.instanceId, parentId(), "root")
}
setDeleteOpen(false)
closeDeleteDialog()
showToastNotification({ message: `Deleted worktree ${target.slug}`, variant: "success" })
})()
.catch((error) => {
log.warn("Failed to delete worktree", error)
showToastNotification({
message: error instanceof Error ? error.message : "Failed to delete worktree",
variant: "error",
})
setDeleteError(error instanceof Error ? error.message : t("instanceShell.worktree.delete.error.fallback"))
})
.finally(() => {
setIsDeleting(false)
@@ -421,6 +569,56 @@ export default function WorktreeSelector(props: WorktreeSelectorProps) {
{isDeleting() ? "Deleting..." : "Delete"}
</button>
</div>
<Show when={displayDeleteError()}>
{(message) => (
<div class="rounded-lg border border-danger bg-danger/10 p-3 flex flex-col gap-2">
<div class="flex flex-col gap-1">
<p class="text-xs font-medium text-danger uppercase tracking-wide">
{t("instanceShell.worktree.delete.error.title")}
</p>
<Show when={deleteErrorDetails()}>
{(details) => (
<>
<p class="text-sm text-primary font-medium">{details().summary}</p>
<p class="text-sm text-secondary">
<span class="font-medium text-primary">{t("instanceShell.worktree.delete.error.causeLabel")}</span>{" "}
{details().causeLabel}
</p>
<p class="text-sm text-secondary">
<span class="font-medium text-primary">{t("instanceShell.worktree.delete.error.nextStepLabel")}</span>{" "}
{details().nextStep}
</p>
</>
)}
</Show>
</div>
<pre class="max-h-[40vh] overflow-auto whitespace-pre-wrap break-all rounded border border-danger/30 bg-surface-primary px-3 py-2 text-xs text-primary select-text leading-5">{message()}</pre>
<div class="grid grid-cols-2 gap-2">
<button
type="button"
class="selector-button selector-button-secondary"
onClick={() => {
void handleCopyDeleteError("raw")
}}
>
{t("instanceShell.worktree.delete.error.copyRaw")}
</button>
<button
type="button"
class="selector-button selector-button-secondary"
onClick={() => {
void handleCopyDeleteError("sanitized")
}}
>
{t("instanceShell.worktree.delete.error.copySanitized")}
</button>
</div>
</div>
)}
</Show>
</Dialog.Content>
</div>
</Dialog.Portal>

View File

@@ -264,6 +264,9 @@ export const serverApi = {
body: JSON.stringify(payload),
})
},
deleteRemoteProxySession(id: string): Promise<void> {
return request(`/api/remote-proxy/sessions/${encodeURIComponent(id)}`, { method: "DELETE" })
},
fetchAuthStatus(): Promise<{ authenticated: boolean; username?: string; passwordUserProvided?: boolean }> {
return request<{ authenticated: boolean; username?: string; passwordUserProvided?: boolean }>("/api/auth/status")
},

View File

@@ -158,6 +158,30 @@ export const instanceMessages = {
"instanceShell.diff.enableWordWrap": "Enable word wrap",
"instanceShell.diff.disableWordWrap": "Disable word wrap",
"instanceShell.worktree.create": "+ Create worktree",
"instanceShell.worktree.delete.error.title": "Delete failed",
"instanceShell.worktree.delete.error.fallback": "Failed to delete worktree",
"instanceShell.worktree.delete.error.causeLabel": "Likely cause:",
"instanceShell.worktree.delete.error.nextStepLabel": "Suggested next step:",
"instanceShell.worktree.delete.error.summary.localChanges": "Git refused to delete this worktree because it has modified or untracked files.",
"instanceShell.worktree.delete.error.summary.inUse": "CodeNomad could not delete this worktree because something is still using files in the directory.",
"instanceShell.worktree.delete.error.summary.notFound": "CodeNomad could not delete this worktree because the directory or worktree record was not found.",
"instanceShell.worktree.delete.error.summary.permissionDenied": "CodeNomad could not delete this worktree because access to the directory was denied.",
"instanceShell.worktree.delete.error.summary.unknown": "CodeNomad could not delete this worktree.",
"instanceShell.worktree.delete.error.cause.localChanges": "Local changes",
"instanceShell.worktree.delete.error.cause.inUse": "Another process is using this worktree",
"instanceShell.worktree.delete.error.cause.notFound": "The worktree directory or record is missing",
"instanceShell.worktree.delete.error.cause.permissionDenied": "Insufficient filesystem permissions",
"instanceShell.worktree.delete.error.cause.unknown": "The backend returned an unclassified delete error",
"instanceShell.worktree.delete.error.nextStep.localChanges": "Enable Force delete if you want to discard local changes, or clean the worktree and try again.",
"instanceShell.worktree.delete.error.nextStep.inUse": "Close terminals, editors, watchers, or background processes using this worktree and try again.",
"instanceShell.worktree.delete.error.nextStep.notFound": "Refresh worktrees and try again. If it still fails, inspect the worktree path on disk.",
"instanceShell.worktree.delete.error.nextStep.permissionDenied": "Check filesystem permissions and close applications that may be locking this directory, then try again.",
"instanceShell.worktree.delete.error.nextStep.unknown": "Review the raw error below for details, then retry after addressing the reported problem.",
"instanceShell.worktree.delete.error.copyRaw": "Copy error",
"instanceShell.worktree.delete.error.copySanitized": "Copy sanitized",
"instanceShell.worktree.delete.error.copySuccess": "Copied delete error",
"instanceShell.worktree.delete.error.copySanitizedSuccess": "Copied sanitized delete error",
"instanceShell.worktree.delete.error.copyFailure": "Failed to copy delete error",
"instanceShell.plan.noSessionSelected": "Select a session to view plan.",
"instanceShell.plan.empty": "Nothing planned yet.",

View File

@@ -166,6 +166,30 @@ export const instanceMessages = {
"instanceShell.backgroundProcesses.actions.output": "Salida",
"instanceShell.backgroundProcesses.actions.stop": "Detener",
"instanceShell.backgroundProcesses.actions.terminate": "Terminar",
"instanceShell.worktree.delete.error.title": "Error al eliminar",
"instanceShell.worktree.delete.error.fallback": "Error al eliminar el worktree",
"instanceShell.worktree.delete.error.causeLabel": "Causa probable:",
"instanceShell.worktree.delete.error.nextStepLabel": "Siguiente paso sugerido:",
"instanceShell.worktree.delete.error.summary.localChanges": "Git rechazo la eliminacion de este worktree porque contiene archivos modificados o sin seguimiento.",
"instanceShell.worktree.delete.error.summary.inUse": "CodeNomad no pudo eliminar este worktree porque algo sigue usando archivos dentro del directorio.",
"instanceShell.worktree.delete.error.summary.notFound": "CodeNomad no pudo eliminar este worktree porque no se encontro el directorio o el registro del worktree.",
"instanceShell.worktree.delete.error.summary.permissionDenied": "CodeNomad no pudo eliminar este worktree porque se denego el acceso al directorio.",
"instanceShell.worktree.delete.error.summary.unknown": "CodeNomad no pudo eliminar este worktree.",
"instanceShell.worktree.delete.error.cause.localChanges": "Cambios locales",
"instanceShell.worktree.delete.error.cause.inUse": "Otro proceso esta usando este worktree",
"instanceShell.worktree.delete.error.cause.notFound": "Falta el directorio o el registro del worktree",
"instanceShell.worktree.delete.error.cause.permissionDenied": "Permisos insuficientes del sistema de archivos",
"instanceShell.worktree.delete.error.cause.unknown": "El backend devolvio un error de eliminacion sin clasificar",
"instanceShell.worktree.delete.error.nextStep.localChanges": "Activa Forzar eliminacion si quieres descartar los cambios locales, o limpia el worktree e intentalo de nuevo.",
"instanceShell.worktree.delete.error.nextStep.inUse": "Cierra terminales, editores, observadores o procesos en segundo plano que usen este worktree y vuelve a intentarlo.",
"instanceShell.worktree.delete.error.nextStep.notFound": "Recarga los worktrees y vuelve a intentarlo. Si sigue fallando, inspecciona la ruta del worktree en disco.",
"instanceShell.worktree.delete.error.nextStep.permissionDenied": "Revisa los permisos del sistema de archivos y cierra aplicaciones que puedan estar bloqueando este directorio, luego vuelve a intentarlo.",
"instanceShell.worktree.delete.error.nextStep.unknown": "Revisa el error sin procesar de abajo para ver los detalles y vuelve a intentarlo despues de corregir el problema indicado.",
"instanceShell.worktree.delete.error.copyRaw": "Copiar error",
"instanceShell.worktree.delete.error.copySanitized": "Copiar saneado",
"instanceShell.worktree.delete.error.copySuccess": "Error de eliminacion copiado",
"instanceShell.worktree.delete.error.copySanitizedSuccess": "Error de eliminacion saneado copiado",
"instanceShell.worktree.delete.error.copyFailure": "No se pudo copiar el error de eliminacion",
"versionPill.appWithVersion": "App {version}",
"versionPill.ui": "UI",

View File

@@ -166,6 +166,30 @@ export const instanceMessages = {
"instanceShell.backgroundProcesses.actions.output": "Sortie",
"instanceShell.backgroundProcesses.actions.stop": "Arrêter",
"instanceShell.backgroundProcesses.actions.terminate": "Terminer",
"instanceShell.worktree.delete.error.title": "Echec de suppression",
"instanceShell.worktree.delete.error.fallback": "Impossible de supprimer le worktree",
"instanceShell.worktree.delete.error.causeLabel": "Cause probable :",
"instanceShell.worktree.delete.error.nextStepLabel": "Etape suivante suggeree :",
"instanceShell.worktree.delete.error.summary.localChanges": "Git a refuse de supprimer ce worktree car il contient des fichiers modifies ou non suivis.",
"instanceShell.worktree.delete.error.summary.inUse": "CodeNomad n'a pas pu supprimer ce worktree car quelque chose utilise encore des fichiers dans ce dossier.",
"instanceShell.worktree.delete.error.summary.notFound": "CodeNomad n'a pas pu supprimer ce worktree car le dossier ou l'enregistrement du worktree est introuvable.",
"instanceShell.worktree.delete.error.summary.permissionDenied": "CodeNomad n'a pas pu supprimer ce worktree car l'acces au dossier a ete refuse.",
"instanceShell.worktree.delete.error.summary.unknown": "CodeNomad n'a pas pu supprimer ce worktree.",
"instanceShell.worktree.delete.error.cause.localChanges": "Modifications locales",
"instanceShell.worktree.delete.error.cause.inUse": "Un autre processus utilise ce worktree",
"instanceShell.worktree.delete.error.cause.notFound": "Le dossier ou l'enregistrement du worktree est manquant",
"instanceShell.worktree.delete.error.cause.permissionDenied": "Permissions du systeme de fichiers insuffisantes",
"instanceShell.worktree.delete.error.cause.unknown": "Le backend a renvoye une erreur de suppression non classee",
"instanceShell.worktree.delete.error.nextStep.localChanges": "Activez la suppression forcee si vous voulez jeter les modifications locales, ou nettoyez le worktree puis reessayez.",
"instanceShell.worktree.delete.error.nextStep.inUse": "Fermez les terminaux, editeurs, observateurs ou processus en arrière-plan qui utilisent ce worktree puis reessayez.",
"instanceShell.worktree.delete.error.nextStep.notFound": "Rechargez les worktrees puis reessayez. Si cela echoue encore, inspectez le chemin du worktree sur le disque.",
"instanceShell.worktree.delete.error.nextStep.permissionDenied": "Verifiez les permissions du systeme de fichiers et fermez les applications qui peuvent verrouiller ce dossier, puis reessayez.",
"instanceShell.worktree.delete.error.nextStep.unknown": "Consultez l'erreur brute ci-dessous pour les details, puis reessayez apres avoir corrige le probleme signale.",
"instanceShell.worktree.delete.error.copyRaw": "Copier l'erreur",
"instanceShell.worktree.delete.error.copySanitized": "Copier la version nettoyee",
"instanceShell.worktree.delete.error.copySuccess": "Erreur de suppression copiee",
"instanceShell.worktree.delete.error.copySanitizedSuccess": "Erreur de suppression nettoyee copiee",
"instanceShell.worktree.delete.error.copyFailure": "Impossible de copier l'erreur de suppression",
"versionPill.appWithVersion": "Appli {version}",
"versionPill.ui": "UI",

View File

@@ -174,6 +174,30 @@ export const instanceMessages = {
"instanceShell.backgroundProcesses.actions.output": "פלט",
"instanceShell.backgroundProcesses.actions.stop": "עצור",
"instanceShell.backgroundProcesses.actions.terminate": "סיים",
"instanceShell.worktree.delete.error.title": "המחיקה נכשלה",
"instanceShell.worktree.delete.error.fallback": "מחיקת ה-worktree נכשלה",
"instanceShell.worktree.delete.error.causeLabel": "סיבה סבירה:",
"instanceShell.worktree.delete.error.nextStepLabel": "השלב הבא המומלץ:",
"instanceShell.worktree.delete.error.summary.localChanges": "Git סירב למחוק את ה-worktree הזה כי יש בו קבצים ששונו או קבצים לא במעקב.",
"instanceShell.worktree.delete.error.summary.inUse": "CodeNomad לא הצליח למחוק את ה-worktree הזה כי משהו עדיין משתמש בקבצים שבתיקייה.",
"instanceShell.worktree.delete.error.summary.notFound": "CodeNomad לא הצליח למחוק את ה-worktree הזה כי התיקייה או רשומת ה-worktree לא נמצאו.",
"instanceShell.worktree.delete.error.summary.permissionDenied": "CodeNomad לא הצליח למחוק את ה-worktree הזה כי הגישה לתיקייה נדחתה.",
"instanceShell.worktree.delete.error.summary.unknown": "CodeNomad לא הצליח למחוק את ה-worktree הזה.",
"instanceShell.worktree.delete.error.cause.localChanges": "שינויים מקומיים",
"instanceShell.worktree.delete.error.cause.inUse": "תהליך אחר משתמש ב-worktree הזה",
"instanceShell.worktree.delete.error.cause.notFound": "תיקיית ה-worktree או הרשומה שלו חסרות",
"instanceShell.worktree.delete.error.cause.permissionDenied": "אין הרשאות מתאימות במערכת הקבצים",
"instanceShell.worktree.delete.error.cause.unknown": "ה-backend החזיר שגיאת מחיקה שלא סווגה",
"instanceShell.worktree.delete.error.nextStep.localChanges": "הפעילו מחיקה בכפייה אם אתם רוצים לזרוק את השינויים המקומיים, או נקו את ה-worktree ונסו שוב.",
"instanceShell.worktree.delete.error.nextStep.inUse": "סגרו טרמינלים, עורכים, watchers או תהליכי רקע שמשתמשים ב-worktree הזה ונסו שוב.",
"instanceShell.worktree.delete.error.nextStep.notFound": "רעננו את רשימת ה-worktrees ונסו שוב. אם זה עדיין נכשל, בדקו את נתיב ה-worktree על הדיסק.",
"instanceShell.worktree.delete.error.nextStep.permissionDenied": "בדקו את הרשאות מערכת הקבצים וסגרו אפליקציות שעשויות לנעול את התיקייה הזאת, ואז נסו שוב.",
"instanceShell.worktree.delete.error.nextStep.unknown": "עיינו בשגיאה הגולמית למטה לפרטים, ואז נסו שוב אחרי טיפול בבעיה שדווחה.",
"instanceShell.worktree.delete.error.copyRaw": "העתק שגיאה",
"instanceShell.worktree.delete.error.copySanitized": "העתק גרסה מסוננת",
"instanceShell.worktree.delete.error.copySuccess": "שגיאת המחיקה הועתקה",
"instanceShell.worktree.delete.error.copySanitizedSuccess": "שגיאת המחיקה המסוננת הועתקה",
"instanceShell.worktree.delete.error.copyFailure": "העתקת שגיאת המחיקה נכשלה",
"versionPill.appWithVersion": "אפליקציה {version}",
"versionPill.ui": "ממשק",

View File

@@ -166,6 +166,30 @@ export const instanceMessages = {
"instanceShell.backgroundProcesses.actions.output": "出力",
"instanceShell.backgroundProcesses.actions.stop": "停止",
"instanceShell.backgroundProcesses.actions.terminate": "終了",
"instanceShell.worktree.delete.error.title": "削除に失敗しました",
"instanceShell.worktree.delete.error.fallback": "worktree の削除に失敗しました",
"instanceShell.worktree.delete.error.causeLabel": "考えられる原因:",
"instanceShell.worktree.delete.error.nextStepLabel": "推奨される次の手順:",
"instanceShell.worktree.delete.error.summary.localChanges": "この worktree に変更済みまたは未追跡のファイルがあるため、Git が削除を拒否しました。",
"instanceShell.worktree.delete.error.summary.inUse": "このディレクトリ内のファイルがまだ使用中のため、CodeNomad はこの worktree を削除できませんでした。",
"instanceShell.worktree.delete.error.summary.notFound": "ディレクトリまたは worktree レコードが見つからなかったため、CodeNomad はこの worktree を削除できませんでした。",
"instanceShell.worktree.delete.error.summary.permissionDenied": "ディレクトリへのアクセスが拒否されたため、CodeNomad はこの worktree を削除できませんでした。",
"instanceShell.worktree.delete.error.summary.unknown": "CodeNomad はこの worktree を削除できませんでした。",
"instanceShell.worktree.delete.error.cause.localChanges": "ローカル変更",
"instanceShell.worktree.delete.error.cause.inUse": "別のプロセスがこの worktree を使用中です",
"instanceShell.worktree.delete.error.cause.notFound": "worktree のディレクトリまたは記録が見つかりません",
"instanceShell.worktree.delete.error.cause.permissionDenied": "ファイルシステム権限が不足しています",
"instanceShell.worktree.delete.error.cause.unknown": "バックエンドが分類できない削除エラーを返しました",
"instanceShell.worktree.delete.error.nextStep.localChanges": "ローカル変更を破棄したい場合は Force delete を有効にするか、worktree を整理してから再試行してください。",
"instanceShell.worktree.delete.error.nextStep.inUse": "この worktree を使用している端末、エディタ、watcher、バックグラウンドプロセスを閉じてから再試行してください。",
"instanceShell.worktree.delete.error.nextStep.notFound": "worktree 一覧を更新して再試行してください。まだ失敗する場合は、ディスク上の worktree パスを確認してください。",
"instanceShell.worktree.delete.error.nextStep.permissionDenied": "ファイルシステム権限を確認し、このディレクトリをロックしている可能性のあるアプリを閉じてから再試行してください。",
"instanceShell.worktree.delete.error.nextStep.unknown": "下の生エラーで詳細を確認し、報告された問題に対処してから再試行してください。",
"instanceShell.worktree.delete.error.copyRaw": "エラーをコピー",
"instanceShell.worktree.delete.error.copySanitized": "サニタイズ済みをコピー",
"instanceShell.worktree.delete.error.copySuccess": "削除エラーをコピーしました",
"instanceShell.worktree.delete.error.copySanitizedSuccess": "サニタイズ済みの削除エラーをコピーしました",
"instanceShell.worktree.delete.error.copyFailure": "削除エラーをコピーできませんでした",
"versionPill.appWithVersion": "アプリ {version}",
"versionPill.ui": "UI",

View File

@@ -166,6 +166,30 @@ export const instanceMessages = {
"instanceShell.backgroundProcesses.actions.output": "Вывод",
"instanceShell.backgroundProcesses.actions.stop": "Остановить",
"instanceShell.backgroundProcesses.actions.terminate": "Завершить",
"instanceShell.worktree.delete.error.title": "Удаление не удалось",
"instanceShell.worktree.delete.error.fallback": "Не удалось удалить worktree",
"instanceShell.worktree.delete.error.causeLabel": "Вероятная причина:",
"instanceShell.worktree.delete.error.nextStepLabel": "Рекомендуемый следующий шаг:",
"instanceShell.worktree.delete.error.summary.localChanges": "Git отказался удалять этот worktree, потому что в нем есть измененные или неотслеживаемые файлы.",
"instanceShell.worktree.delete.error.summary.inUse": "CodeNomad не смог удалить этот worktree, потому что что-то все еще использует файлы в каталоге.",
"instanceShell.worktree.delete.error.summary.notFound": "CodeNomad не смог удалить этот worktree, потому что каталог или запись worktree не найдены.",
"instanceShell.worktree.delete.error.summary.permissionDenied": "CodeNomad не смог удалить этот worktree, потому что доступ к каталогу был запрещен.",
"instanceShell.worktree.delete.error.summary.unknown": "CodeNomad не смог удалить этот worktree.",
"instanceShell.worktree.delete.error.cause.localChanges": "Локальные изменения",
"instanceShell.worktree.delete.error.cause.inUse": "Другой процесс использует этот worktree",
"instanceShell.worktree.delete.error.cause.notFound": "Каталог или запись worktree отсутствуют",
"instanceShell.worktree.delete.error.cause.permissionDenied": "Недостаточно прав файловой системы",
"instanceShell.worktree.delete.error.cause.unknown": "Бэкенд вернул неклассифицированную ошибку удаления",
"instanceShell.worktree.delete.error.nextStep.localChanges": "Включите принудительное удаление, если хотите отбросить локальные изменения, либо очистите worktree и попробуйте снова.",
"instanceShell.worktree.delete.error.nextStep.inUse": "Закройте терминалы, редакторы, watcher-процессы или фоновые процессы, использующие этот worktree, и попробуйте снова.",
"instanceShell.worktree.delete.error.nextStep.notFound": "Обновите список worktree и попробуйте снова. Если ошибка сохранится, проверьте путь worktree на диске.",
"instanceShell.worktree.delete.error.nextStep.permissionDenied": "Проверьте права файловой системы и закройте приложения, которые могут удерживать этот каталог, затем попробуйте снова.",
"instanceShell.worktree.delete.error.nextStep.unknown": "Посмотрите необработанную ошибку ниже, затем попробуйте снова после устранения указанной проблемы.",
"instanceShell.worktree.delete.error.copyRaw": "Копировать ошибку",
"instanceShell.worktree.delete.error.copySanitized": "Копировать обезличенную",
"instanceShell.worktree.delete.error.copySuccess": "Ошибка удаления скопирована",
"instanceShell.worktree.delete.error.copySanitizedSuccess": "Обезличенная ошибка удаления скопирована",
"instanceShell.worktree.delete.error.copyFailure": "Не удалось скопировать ошибку удаления",
"versionPill.appWithVersion": "Приложение {version}",
"versionPill.ui": "UI",

View File

@@ -166,6 +166,30 @@ export const instanceMessages = {
"instanceShell.backgroundProcesses.actions.output": "输出",
"instanceShell.backgroundProcesses.actions.stop": "停止",
"instanceShell.backgroundProcesses.actions.terminate": "终止",
"instanceShell.worktree.delete.error.title": "删除失败",
"instanceShell.worktree.delete.error.fallback": "删除 worktree 失败",
"instanceShell.worktree.delete.error.causeLabel": "可能原因:",
"instanceShell.worktree.delete.error.nextStepLabel": "建议的下一步:",
"instanceShell.worktree.delete.error.summary.localChanges": "Git 拒绝删除这个 worktree因为其中包含已修改或未跟踪的文件。",
"instanceShell.worktree.delete.error.summary.inUse": "CodeNomad 无法删除这个 worktree因为目录中的文件仍在被某些进程使用。",
"instanceShell.worktree.delete.error.summary.notFound": "CodeNomad 无法删除这个 worktree因为目录或 worktree 记录未找到。",
"instanceShell.worktree.delete.error.summary.permissionDenied": "CodeNomad 无法删除这个 worktree因为目录访问被拒绝。",
"instanceShell.worktree.delete.error.summary.unknown": "CodeNomad 无法删除这个 worktree。",
"instanceShell.worktree.delete.error.cause.localChanges": "本地更改",
"instanceShell.worktree.delete.error.cause.inUse": "另一个进程正在使用这个 worktree",
"instanceShell.worktree.delete.error.cause.notFound": "worktree 目录或记录缺失",
"instanceShell.worktree.delete.error.cause.permissionDenied": "文件系统权限不足",
"instanceShell.worktree.delete.error.cause.unknown": "后端返回了未分类的删除错误",
"instanceShell.worktree.delete.error.nextStep.localChanges": "如果你想丢弃本地更改,请启用强制删除,或者先清理 worktree 后再重试。",
"instanceShell.worktree.delete.error.nextStep.inUse": "关闭正在使用这个 worktree 的终端、编辑器、watcher 或后台进程,然后再试一次。",
"instanceShell.worktree.delete.error.nextStep.notFound": "刷新 worktree 列表后再试一次。如果仍然失败,请检查磁盘上的 worktree 路径。",
"instanceShell.worktree.delete.error.nextStep.permissionDenied": "检查文件系统权限,并关闭可能锁定此目录的应用程序,然后再试一次。",
"instanceShell.worktree.delete.error.nextStep.unknown": "查看下方原始错误详情,并在处理提示的问题后再次重试。",
"instanceShell.worktree.delete.error.copyRaw": "复制错误",
"instanceShell.worktree.delete.error.copySanitized": "复制脱敏内容",
"instanceShell.worktree.delete.error.copySuccess": "已复制删除错误",
"instanceShell.worktree.delete.error.copySanitizedSuccess": "已复制脱敏后的删除错误",
"instanceShell.worktree.delete.error.copyFailure": "复制删除错误失败",
"versionPill.appWithVersion": "应用 {version}",
"versionPill.ui": "UI",

View File

@@ -7,18 +7,21 @@ export interface RemoteWindowOpenPayload {
name: string
baseUrl: string
entryUrl?: string
proxySessionId?: string
skipTlsVerify: boolean
}
export async function openRemoteServerWindow(
profile: Pick<RemoteServerProfile, "id" | "name" | "baseUrl" | "skipTlsVerify">,
entryUrl?: string,
proxySessionId?: string,
): Promise<void> {
const payload: RemoteWindowOpenPayload = {
id: profile.id,
name: profile.name,
baseUrl: profile.baseUrl,
entryUrl,
proxySessionId,
skipTlsVerify: profile.skipTlsVerify,
}

View File

@@ -397,7 +397,8 @@ function handleMessageUpdate(instanceId: string, event: MessageUpdateEvent | Mes
const role: MessageRole = info.role === "user" ? "user" : "assistant"
const hasError = Boolean((info as any).error)
const status: MessageStatus = hasError ? "error" : "complete"
const hasEnded = typeof timeInfo.end === "number" && timeInfo.end > 0
const status: MessageStatus = hasError ? "error" : hasEnded ? "complete" : "streaming"
let record = store.getMessage(messageId)
if (!record) {

View File

@@ -6,7 +6,7 @@
.prompt-input-wrapper {
@apply grid items-stretch;
grid-template-columns: minmax(0, 1fr) 64px;
grid-template-columns: minmax(0, 1fr) 72px 64px;
gap: 0;
padding: 0;
}
@@ -19,6 +19,16 @@
gap: 0.5rem;
}
.prompt-input-primary-actions {
@apply flex flex-col items-center;
align-self: stretch;
justify-content: space-between;
width: 100%;
gap: 0.5rem;
padding: 0.5rem 0.25rem;
border-inline-start: 1px solid var(--border-base);
}
.prompt-input-field-container {
position: relative;
width: 100%;
@@ -37,7 +47,7 @@
.prompt-input {
@apply w-full pt-2.5 border text-sm resize-none outline-none transition-colors;
padding-inline-start: 0.75rem;
padding-inline-end: 7.5rem;
padding-inline-end: 0.75rem;
font-family: inherit;
background-color: var(--surface-base);
color: var(--text-primary);
@@ -85,16 +95,12 @@
/* Navigation buttons container (expand, prev, next). */
.prompt-nav-buttons {
position: absolute;
top: 0.25rem;
inset-inline-end: 0.25rem;
bottom: 0.25rem;
display: flex;
flex-direction: row;
align-items: flex-start;
justify-content: flex-end;
justify-content: center;
gap: 0.125rem;
z-index: 2;
width: 100%;
}
.prompt-nav-column {
@@ -287,7 +293,6 @@
@apply w-10 h-10 rounded-md border-none cursor-pointer flex items-center justify-center transition-all flex-shrink-0;
background-color: var(--accent-primary);
color: var(--text-inverted);
margin-top: auto;
}
.send-button.shell-mode {
@@ -421,7 +426,7 @@
@media (max-width: 720px) {
.prompt-input-wrapper {
grid-template-columns: minmax(0, 1fr) 40px;
grid-template-columns: minmax(0, 1fr) 64px 40px;
}
}
@@ -429,7 +434,6 @@
.prompt-input {
min-height: 0;
padding: 0.5rem 0.75rem;
padding-inline-end: 7.5rem;
padding-bottom: 0.75rem;
}

View File

@@ -611,6 +611,40 @@
z-index: 30;
}
.file-viewer-content--monaco .monaco-viewer[data-view-mode="unified"] .line-numbers {
text-align: left !important;
padding-left: 4px;
}
.file-viewer-content--monaco .monaco-viewer[data-view-mode="split"] .editor.original .line-numbers,
.file-viewer-content--monaco .monaco-viewer[data-view-mode="split"] .editor.modified .line-numbers {
text-align: left !important;
padding-left: 4px;
}
.file-viewer-content--monaco .monaco-viewer[data-view-mode="split"] .editor.original .glyph-margin {
width: 0 !important;
}
.file-viewer-content--monaco .monaco-viewer[data-view-mode="split"] .editor.original .line-numbers {
left: 0 !important;
}
.file-viewer-content--monaco .monaco-viewer[data-view-mode="split"] .editor.original .cldr.delete-sign {
left: var(--split-original-delete-sign-left, 14px) !important;
}
.file-viewer-content--monaco .monaco-viewer[data-view-mode="split"] .editor.original .margin,
.file-viewer-content--monaco .monaco-viewer[data-view-mode="split"] .editor.original .margin-view-zones,
.file-viewer-content--monaco .monaco-viewer[data-view-mode="split"] .editor.original .margin-view-overlays {
width: var(--split-original-gutter-width, 24px) !important;
}
.file-viewer-content--monaco .monaco-viewer[data-view-mode="split"] .editor.original .editor-scrollable {
left: var(--split-original-gutter-width, 24px) !important;
width: calc(100% - var(--split-original-gutter-width, 24px)) !important;
}
.file-viewer-empty {
@apply flex flex-col items-center justify-center h-full gap-3 text-center;
color: var(--text-muted);

View File

@@ -38,6 +38,7 @@ declare global {
name: string
baseUrl: string
entryUrl?: string
proxySessionId?: string
skipTlsVerify: boolean
}) => Promise<{ ok: boolean }>
}