Compare commits

..

4 Commits

Author SHA1 Message Date
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
33 changed files with 707 additions and 6155 deletions

1176
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -29,8 +29,6 @@
"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"
}
}

View File

@@ -337,15 +337,6 @@ export interface RemoteServerProbeResponse {
errorCode?: string
}
export interface RemoteProxySessionCreateRequest {
baseUrl: string
skipTlsVerify?: boolean
}
export interface RemoteProxySessionCreateResponse {
windowUrl: string
}
export type WorkspaceEventType =
| "workspace.created"
| "workspace.started"

View File

@@ -21,7 +21,6 @@ import { launchInBrowser } from "./launcher"
import { resolveUi } from "./ui/remote-ui"
import { AuthManager, BOOTSTRAP_TOKEN_STDOUT_PREFIX, DEFAULT_AUTH_COOKIE_NAME, DEFAULT_AUTH_USERNAME } from "./auth/manager"
import { resolveHttpsOptions } from "./server/tls"
import { RemoteProxySessionManager } from "./server/remote-proxy"
import { resolveNetworkAddresses, resolveRemoteAddresses } from "./server/network-addresses"
import { startDevReleaseMonitor } from "./releases/dev-release-monitor"
import { SpeechService } from "./speech/service"
@@ -384,11 +383,6 @@ async function main() {
const clientConnectionManager = new ClientConnectionManager(logger.child({ component: "client-connections" }))
const pluginChannel = new PluginChannelManager(logger.child({ component: "plugin-channel" }))
const remoteProxySessionManager = new RemoteProxySessionManager({
authManager,
logger: logger.child({ component: "remote-proxy" }),
httpsOptions: tlsResolution?.httpsOptions,
})
const voiceModeManager = new VoiceModeManager({
connections: clientConnectionManager,
channel: pluginChannel,
@@ -428,7 +422,6 @@ async function main() {
clientConnectionManager,
pluginChannel,
voiceModeManager,
remoteProxySessionManager,
uiStaticDir: uiResolution.uiStaticDir ?? DEFAULT_UI_STATIC_DIR,
uiDevServerUrl: uiResolution.uiDevServerUrl,
logger,
@@ -454,7 +447,6 @@ async function main() {
clientConnectionManager,
pluginChannel,
voiceModeManager,
remoteProxySessionManager,
uiStaticDir: uiResolution.uiStaticDir ?? DEFAULT_UI_STATIC_DIR,
uiDevServerUrl: undefined,
logger,

View File

@@ -26,7 +26,6 @@ import { registerBackgroundProcessRoutes } from "./routes/background-processes"
import { registerWorktreeRoutes } from "./routes/worktrees"
import { registerSpeechRoutes } from "./routes/speech"
import { registerRemoteServerRoutes } from "./routes/remote-servers"
import { registerRemoteProxyRoutes } from "./routes/remote-proxy"
import { registerSideCarRoutes } from "./routes/sidecars"
import { ServerMeta } from "../api-types"
import { InstanceStore } from "../storage/instance-store"
@@ -39,7 +38,6 @@ import { ClientConnectionManager } from "../clients/connection-manager"
import { PluginChannelManager } from "../plugins/channel"
import { VoiceModeManager } from "../plugins/voice-mode"
import type { SideCarManager } from "../sidecars/manager"
import type { RemoteProxySessionManager } from "./remote-proxy"
interface HttpServerDeps {
bindHost: string
@@ -60,7 +58,6 @@ interface HttpServerDeps {
clientConnectionManager: ClientConnectionManager
pluginChannel: PluginChannelManager
voiceModeManager: VoiceModeManager
remoteProxySessionManager: RemoteProxySessionManager
uiStaticDir: string
uiDevServerUrl?: string
logger: Logger
@@ -277,7 +274,6 @@ export function createHttpServer(deps: HttpServerDeps) {
workspaceManager: deps.workspaceManager,
})
registerRemoteServerRoutes(app, { logger: apiLogger })
registerRemoteProxyRoutes(app, { logger: proxyLogger, sessionManager: deps.remoteProxySessionManager })
registerSpeechRoutes(app, { speechService: deps.speechService })
registerSideCarRoutes(app, { sidecarManager: deps.sidecarManager })
registerSideCarProxyRoutes(app, { sidecarManager: deps.sidecarManager, logger: proxyLogger })

View File

@@ -1,533 +0,0 @@
import Fastify, { type FastifyInstance, type FastifyReply, type FastifyRequest } from "fastify"
import { randomBytes, randomUUID } from "crypto"
import { Readable } from "stream"
import { Agent, fetch } from "undici"
import type { AuthManager } from "../auth/manager"
import type { Logger } from "../logger"
const LOOPBACK_HOST = "127.0.0.1"
const BOOTSTRAP_PAGE_PATH = "/__codenomad/auth/token"
const BOOTSTRAP_EXCHANGE_PATH = "/__codenomad/api/auth/token"
const SESSION_IDLE_TTL_MS = 30 * 60_000
interface RemoteProxySession {
id: string
targetBaseUrl: URL
skipTlsVerify: boolean
localBaseUrl: URL
entryUrl: URL
bootstrapUrl: string
activated: boolean
cookiePrefix: string
app: FastifyInstance
dispatcher?: Agent
createdAt: number
lastAccessAt: number
}
export interface RemoteProxySessionManagerOptions {
authManager: AuthManager
logger: Logger
httpsOptions?: { key: string | Buffer; cert: string | Buffer; ca?: string | Buffer }
}
export class RemoteProxySessionManager {
private readonly sessions = new Map<string, RemoteProxySession>()
private readonly cleanupTimer: NodeJS.Timeout
constructor(private readonly options: RemoteProxySessionManagerOptions) {
this.cleanupTimer = setInterval(() => {
void this.cleanupExpiredSessions()
}, 60_000)
this.cleanupTimer.unref()
}
async createSession(baseUrl: string, skipTlsVerify: boolean): Promise<string> {
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 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))
app.get(BOOTSTRAP_PAGE_PATH, async (request, reply) => {
if (!this.options.authManager.isLoopbackRequest(request)) {
reply.code(404).send({ error: "Not found" })
return
}
reply.header("Cache-Control", "no-store")
reply.header("Pragma", "no-cache")
reply.header("Expires", "0")
reply.type("text/html").send(buildBootstrapPageHtml())
})
app.post(BOOTSTRAP_EXCHANGE_PATH, async (request, reply) => {
if (!this.options.authManager.isLoopbackRequest(request)) {
reply.code(404).send({ error: "Not found" })
return
}
const body = parseTokenBody(request.body)
if (!this.options.authManager.consumeBootstrapToken(body.token)) {
reply.code(401).send({ error: "Invalid token" })
return
}
if (!session) {
reply.code(503).send({ error: "Remote proxy session is unavailable" })
return
}
session.activated = true
session.lastAccessAt = Date.now()
reply.send({ ok: true })
})
app.all("/*", async (request, reply) => {
if (!session) {
reply.code(503).send({ error: "Remote proxy session is unavailable" })
return
}
if (!session.activated) {
reply.code(403).send({ error: "Remote proxy session is not activated" })
return
}
session.lastAccessAt = Date.now()
await proxyRequest({ request, reply, session, logger: this.options.logger })
})
app.setNotFoundHandler(async (request, reply) => {
if (!session) {
reply.code(503).send({ error: "Remote proxy session is unavailable" })
return
}
if (!session.activated) {
reply.code(403).send({ error: "Remote proxy session is not activated" })
return
}
session.lastAccessAt = Date.now()
await proxyRequest({ request, reply, session, logger: this.options.logger })
})
const addressInfo = await app.listen({ host: LOOPBACK_HOST, port: 0 })
const address = new URL(addressInfo)
const localBaseUrl = new URL(`https://${LOOPBACK_HOST}:${address.port}`)
const entryUrl = new URL(targetBaseUrl.pathname || "/", localBaseUrl)
const returnTo = buildReturnToTarget(entryUrl)
session = {
id: sessionId,
targetBaseUrl,
skipTlsVerify,
localBaseUrl,
entryUrl,
bootstrapUrl: `${localBaseUrl.origin}${BOOTSTRAP_PAGE_PATH}?returnTo=${encodeURIComponent(returnTo)}#${encodeURIComponent(token)}`,
activated: false,
cookiePrefix: `cnrp_${randomBytes(6).toString("hex")}_`,
app,
dispatcher,
createdAt: Date.now(),
lastAccessAt: Date.now(),
}
this.sessions.set(sessionId, session)
this.options.logger.info(
{ sessionId, targetBaseUrl: targetBaseUrl.toString(), localBaseUrl: localBaseUrl.toString() },
"Created remote proxy session",
)
return session.bootstrapUrl
}
private async cleanupExpiredSessions() {
const now = Date.now()
for (const session of Array.from(this.sessions.values())) {
if (now - session.lastAccessAt <= SESSION_IDLE_TTL_MS) {
continue
}
await this.disposeSession(session.id)
}
}
private async disposeSession(sessionId: string) {
const session = this.sessions.get(sessionId)
if (!session) {
return
}
this.sessions.delete(sessionId)
session.dispatcher?.close().catch(() => {})
await session.app.close().catch(() => {})
this.options.logger.info({ sessionId }, "Disposed remote proxy session")
}
}
function normalizeBaseUrl(input: string): URL {
const parsed = new URL(input.trim())
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
throw new Error("Server URL must use http:// or https://")
}
parsed.hash = ""
parsed.search = ""
parsed.pathname = parsed.pathname === "/" ? "/" : parsed.pathname.replace(/\/+$/, "") || "/"
return parsed
}
function buildReturnToTarget(entryUrl: URL): string {
const query = entryUrl.search ? entryUrl.search : ""
return `${entryUrl.pathname || "/"}${query}`
}
function buildBootstrapPageHtml(): string {
return `<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>CodeNomad</title>
<style>
body { font-family: ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, Helvetica, Arial; background: #0b0b0f; color: #fff; display: flex; align-items: center; justify-content: center; height: 100vh; margin: 0; }
.card { width: 420px; max-width: calc(100vw - 32px); background: #14141c; border: 1px solid rgba(255,255,255,0.08); border-radius: 14px; padding: 24px; }
h1 { font-size: 18px; margin: 0 0 12px; }
p { margin: 0; color: rgba(255,255,255,0.7); font-size: 13px; line-height: 1.4; }
.error { margin-top: 12px; color: #ff6b6b; font-size: 13px; display: none; }
</style>
</head>
<body>
<div class="card">
<h1>Connecting...</h1>
<p>Finalizing local authentication.</p>
<div id="error" class="error"></div>
</div>
<script>
const token = decodeURIComponent((location.hash || "").replace(/^#/, "").trim())
const params = new URLSearchParams(location.search)
const returnTo = sanitizeReturnTo(params.get("returnTo"))
const errorEl = document.getElementById("error")
function sanitizeReturnTo(value) {
if (!value || typeof value !== "string") return "/"
if (!value.startsWith("/")) return "/"
if (value.startsWith("//")) return "/"
return value
}
function showError(message) {
errorEl.textContent = message
errorEl.style.display = "block"
}
async function run() {
if (!token) {
showError("Missing bootstrap token.")
return
}
try {
const res = await fetch("${BOOTSTRAP_EXCHANGE_PATH}", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ token }),
credentials: "include",
})
if (!res.ok) {
let message = ""
try {
const json = await res.json()
message = json && json.error ? String(json.error) : ""
} catch {
message = ""
}
showError(message || "Token exchange failed (" + res.status + ")")
return
}
window.location.replace(returnTo)
} catch (error) {
showError(error && error.message ? error.message : String(error))
}
}
run()
</script>
</body>
</html>`
}
function parseTokenBody(body: unknown): { token: string } {
const value = normalizeJsonBody(body) as { token?: unknown } | null | undefined
const token = typeof value?.token === "string" ? value.token.trim() : ""
if (!token) {
throw new Error("Missing bootstrap token")
}
return { token }
}
function normalizeJsonBody(body: unknown): unknown {
if (Buffer.isBuffer(body)) {
return JSON.parse(body.toString("utf-8"))
}
if (typeof body === "string") {
return JSON.parse(body)
}
return body
}
function toRequestBody(body: unknown): any {
if (body == null) {
return undefined
}
if (Buffer.isBuffer(body) || typeof body === "string" || body instanceof Uint8Array) {
return body
}
return JSON.stringify(body)
}
async function proxyRequest(args: {
request: FastifyRequest
reply: FastifyReply
session: RemoteProxySession
logger: Logger
}) {
const { request, reply, session, logger } = args
const upstreamUrl = buildUpstreamUrl(session.targetBaseUrl, request.raw.url ?? request.url)
const headers = filterRequestHeaders(request.headers, session)
const init: any = {
method: request.method,
headers,
dispatcher: session.dispatcher,
redirect: "manual",
}
if (request.method !== "GET" && request.method !== "HEAD") {
const body = toRequestBody(request.body)
if (body !== undefined) {
init.body = body
init.duplex = "half"
}
}
try {
const response = await fetch(upstreamUrl, init as any)
reply.code(response.status)
applyResponseHeaders(reply, response, session)
if (!response.body || request.method === "HEAD") {
reply.send()
return
}
reply.send(Readable.fromWeb(response.body as any))
} catch (error) {
logger.error({ err: error, upstreamUrl }, "Failed to proxy remote session request")
if (!reply.sent) {
reply.code(502).send({ error: "Remote proxy request failed" })
}
}
}
function buildUpstreamUrl(baseUrl: URL, rawUrl: string): string {
const parsed = new URL(rawUrl, "https://localhost")
const url = new URL(baseUrl.toString())
url.pathname = rewriteRequestPath(baseUrl, parsed.pathname)
url.search = stripInternalQuery(parsed.search)
url.hash = ""
return url.toString()
}
function rewriteRequestPath(baseUrl: URL, requestPath: string): string {
const basePath = normalizedBasePath(baseUrl)
if (basePath === "/") {
return requestPath
}
if (requestPath === "/") {
return basePath
}
if (pathHasBasePrefix(basePath, requestPath)) {
return requestPath
}
return `${basePath}${requestPath}`
}
function normalizedBasePath(baseUrl: URL): string {
return baseUrl.pathname || "/"
}
function pathHasBasePrefix(basePath: string, requestPath: string): boolean {
return requestPath === basePath || requestPath.startsWith(`${basePath}/`)
}
function stripInternalQuery(search: string): string {
if (!search || search === "?") {
return ""
}
return search
}
function filterRequestHeaders(
headers: FastifyRequest["headers"],
session: RemoteProxySession,
): Record<string, string> {
const next: Record<string, string> = {}
for (const [key, value] of Object.entries(headers ?? {})) {
if (!value) continue
const lower = key.toLowerCase()
if (isHopByHopHeader(lower) || lower === "host" || lower === "content-length") {
continue
}
if (lower === "origin") {
next[key] = session.targetBaseUrl.origin
continue
}
if (lower === "referer") {
const rewritten = rewriteRefererHeader(Array.isArray(value) ? value[0] : value, session.targetBaseUrl)
if (rewritten) {
next[key] = rewritten
}
continue
}
if (lower === "cookie") {
const rewritten = rewriteRequestCookieHeader(Array.isArray(value) ? value.join("; ") : value, session.cookiePrefix)
if (rewritten) {
next[key] = rewritten
}
continue
}
next[key] = Array.isArray(value) ? value.join(",") : value
}
next.host = session.targetBaseUrl.port ? `${session.targetBaseUrl.hostname}:${session.targetBaseUrl.port}` : session.targetBaseUrl.hostname
if (!next.origin) {
next.origin = session.targetBaseUrl.origin
}
return next
}
function rewriteRefererHeader(referer: string | undefined, targetBaseUrl: URL): string | null {
if (!referer) {
return null
}
try {
const parsed = new URL(referer)
const rewritten = new URL(targetBaseUrl.toString())
rewritten.pathname = rewriteRequestPath(targetBaseUrl, parsed.pathname)
rewritten.search = parsed.search
rewritten.hash = parsed.hash
return rewritten.toString()
} catch {
return null
}
}
function applyResponseHeaders(reply: FastifyReply, response: any, session: RemoteProxySession) {
const setCookie = (response.headers as any).getSetCookie?.() as string[] | undefined
if (Array.isArray(setCookie)) {
for (const cookie of setCookie) {
reply.header("set-cookie", rewriteSetCookie(cookie, session.cookiePrefix))
}
}
response.headers.forEach((value: string, key: string) => {
const lower = key.toLowerCase()
if (isHopByHopHeader(lower) || lower === "set-cookie") {
return
}
if (lower === "location") {
reply.header(key, rewriteLocation(value, session.targetBaseUrl, session.localBaseUrl))
return
}
reply.header(key, value)
})
}
function rewriteSetCookie(cookie: string, cookiePrefix: string): string {
const parts = cookie.split(";").map((part) => part.trim())
const first = parts.shift() ?? ""
const separator = first.indexOf("=")
if (separator <= 0) {
return cookie
}
const name = first.slice(0, separator).trim()
const value = first.slice(separator + 1)
const rewritten = [`${cookiePrefix}${name}=${value}`]
for (const part of parts) {
if (part.slice(0, 7).toLowerCase().startsWith("domain=")) {
continue
}
rewritten.push(part)
}
return rewritten.join("; ")
}
function rewriteRequestCookieHeader(cookieHeader: string, cookiePrefix: string): string {
const next: string[] = []
for (const rawPart of cookieHeader.split(";")) {
const part = rawPart.trim()
if (!part) continue
const separator = part.indexOf("=")
if (separator <= 0) continue
const name = part.slice(0, separator).trim()
const value = part.slice(separator + 1)
if (!name.startsWith(cookiePrefix)) {
continue
}
next.push(`${name.slice(cookiePrefix.length)}=${value}`)
}
return next.join("; ")
}
function rewriteLocation(location: string, targetBaseUrl: URL, localBaseUrl: URL): string {
try {
const parsed = new URL(location, targetBaseUrl)
if (parsed.origin !== targetBaseUrl.origin) {
return location
}
const rewritten = new URL(localBaseUrl.toString())
rewritten.pathname = parsed.pathname
rewritten.search = parsed.search
rewritten.hash = parsed.hash
return rewritten.toString()
} catch {
return location
}
}
function isHopByHopHeader(name: string): boolean {
return new Set([
"connection",
"keep-alive",
"proxy-authenticate",
"proxy-authorization",
"te",
"trailer",
"transfer-encoding",
"upgrade",
]).has(name)
}

View File

@@ -1,29 +0,0 @@
import type { FastifyInstance } from "fastify"
import { z } from "zod"
import type { RemoteProxySessionCreateResponse } from "../../api-types"
import type { Logger } from "../../logger"
import type { RemoteProxySessionManager } from "../remote-proxy"
interface RouteDeps {
logger: Logger
sessionManager: RemoteProxySessionManager
}
const CreateSessionSchema = z.object({
baseUrl: z.string().min(1),
skipTlsVerify: z.boolean().optional(),
})
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 }
} 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" }
}
})
}

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"
@@ -222,105 +213,6 @@ version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8"
[[package]]
name = "aws-lc-rs"
version = "1.16.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a054912289d18629dc78375ba2c3726a3afe3ff71b4edba9dedfca0e3446d1fc"
dependencies = [
"aws-lc-sys",
"zeroize",
]
[[package]]
name = "aws-lc-sys"
version = "0.39.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "83a25cf98105baa966497416dbd42565ce3a8cf8dbfd59803ec9ad46f3126399"
dependencies = [
"cc",
"cmake",
"dunce",
"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"
@@ -516,8 +408,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7a0dd1ca384932ff3641c8718a02769f1698e7563dc6974ffd03346116310423"
dependencies = [
"find-msvc-tools",
"jobserver",
"libc",
"shlex",
]
@@ -554,12 +444,6 @@ version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
[[package]]
name = "cfg_aliases"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724"
[[package]]
name = "chrono"
version = "0.4.44"
@@ -572,34 +456,17 @@ dependencies = [
"windows-link 0.2.1",
]
[[package]]
name = "cmake"
version = "0.1.58"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c0f78a02292a74a88ac736019ab962ece0bc380e3f977bf72e376c5d78ff0678"
dependencies = [
"cc",
]
[[package]]
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",
"serde",
"serde_json",
"serde_yaml",
@@ -610,9 +477,7 @@ dependencies = [
"tauri-plugin-notification",
"tauri-plugin-opener",
"thiserror 1.0.69",
"tokio",
"url",
"webkit2gtk",
"which",
"windows-sys 0.59.0",
]
@@ -1104,15 +969,6 @@ version = "1.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4ef6b89e5b37196644d8796de5268852ff179b44e96276cf4290264843743bb7"
[[package]]
name = "encoding_rs"
version = "0.8.35"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3"
dependencies = [
"cfg-if",
]
[[package]]
name = "endi"
version = "1.1.1"
@@ -1283,22 +1139,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"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c"
[[package]]
name = "futf"
version = "0.1.5"
@@ -1539,10 +1379,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0"
dependencies = [
"cfg-if",
"js-sys",
"libc",
"wasi 0.11.1+wasi-snapshot-preview1",
"wasm-bindgen",
]
[[package]]
@@ -1552,11 +1390,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd"
dependencies = [
"cfg-if",
"js-sys",
"libc",
"r-efi 5.3.0",
"wasip2",
"wasm-bindgen",
]
[[package]]
@@ -1738,25 +1574,6 @@ dependencies = [
"syn 2.0.117",
]
[[package]]
name = "h2"
version = "0.4.13"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2f44da3a8150a6703ed5d34e164b875fd14c2cdab9af1252a9a1020bde2bdc54"
dependencies = [
"atomic-waker",
"bytes",
"fnv",
"futures-core",
"futures-sink",
"http",
"indexmap 2.13.0",
"slab",
"tokio",
"tokio-util",
"tracing",
]
[[package]]
name = "hashbrown"
version = "0.12.3"
@@ -1872,12 +1689,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"
@@ -1888,11 +1699,9 @@ dependencies = [
"bytes",
"futures-channel",
"futures-core",
"h2",
"http",
"http-body",
"httparse",
"httpdate",
"itoa",
"pin-project-lite",
"pin-utils",
@@ -1901,23 +1710,6 @@ dependencies = [
"want",
]
[[package]]
name = "hyper-rustls"
version = "0.27.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e3c93eb611681b207e1fe55d5a71ecf91572ec8a6705cdb6857f7d8d5242cf58"
dependencies = [
"http",
"hyper",
"hyper-util",
"rustls",
"rustls-pki-types",
"tokio",
"tokio-rustls",
"tower-service",
"webpki-roots",
]
[[package]]
name = "hyper-util"
version = "0.1.20"
@@ -2207,16 +1999,6 @@ version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8eaf4bc02d17cbdd7ff4c7438cafcdf7fb9a4613313ad11b4f8fefe7d3fa0130"
[[package]]
name = "jobserver"
version = "0.1.34"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33"
dependencies = [
"getrandom 0.3.4",
"libc",
]
[[package]]
name = "js-sys"
version = "0.3.91"
@@ -2375,12 +2157,6 @@ version = "0.4.29"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897"
[[package]]
name = "lru-slab"
version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154"
[[package]]
name = "mac"
version = "0.1.1"
@@ -2441,12 +2217,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"
@@ -3225,61 +2995,6 @@ dependencies = [
"memchr",
]
[[package]]
name = "quinn"
version = "0.11.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b9e20a958963c291dc322d98411f541009df2ced7b5a4f2bd52337638cfccf20"
dependencies = [
"bytes",
"cfg_aliases",
"pin-project-lite",
"quinn-proto",
"quinn-udp",
"rustc-hash",
"rustls",
"socket2",
"thiserror 2.0.18",
"tokio",
"tracing",
"web-time",
]
[[package]]
name = "quinn-proto"
version = "0.11.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "434b42fec591c96ef50e21e886936e66d3cc3f737104fdb9b737c40ffb94c098"
dependencies = [
"bytes",
"getrandom 0.3.4",
"lru-slab",
"rand 0.9.2",
"ring",
"rustc-hash",
"rustls",
"rustls-pki-types",
"slab",
"thiserror 2.0.18",
"tinyvec",
"tracing",
"web-time",
]
[[package]]
name = "quinn-udp"
version = "0.5.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "addec6a0dcad8a8d96a771f815f0eaf55f9d1805756410b39f5fa81332574cbd"
dependencies = [
"cfg_aliases",
"libc",
"once_cell",
"socket2",
"tracing",
"windows-sys 0.60.2",
]
[[package]]
name = "quote"
version = "1.0.45"
@@ -3497,50 +3212,6 @@ version = "0.8.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a"
[[package]]
name = "reqwest"
version = "0.12.28"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147"
dependencies = [
"base64 0.22.1",
"bytes",
"encoding_rs",
"futures-core",
"futures-util",
"h2",
"http",
"http-body",
"http-body-util",
"hyper",
"hyper-rustls",
"hyper-util",
"js-sys",
"log",
"mime",
"percent-encoding",
"pin-project-lite",
"quinn",
"rustls",
"rustls-pki-types",
"serde",
"serde_json",
"serde_urlencoded",
"sync_wrapper",
"tokio",
"tokio-rustls",
"tokio-util",
"tower",
"tower-http",
"tower-service",
"url",
"wasm-bindgen",
"wasm-bindgen-futures",
"wasm-streams 0.4.2",
"web-sys",
"webpki-roots",
]
[[package]]
name = "reqwest"
version = "0.13.2"
@@ -3571,7 +3242,7 @@ dependencies = [
"url",
"wasm-bindgen",
"wasm-bindgen-futures",
"wasm-streams 0.5.0",
"wasm-streams",
"web-sys",
]
@@ -3599,20 +3270,6 @@ dependencies = [
"windows-sys 0.60.2",
]
[[package]]
name = "ring"
version = "0.17.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7"
dependencies = [
"cc",
"cfg-if",
"getrandom 0.2.17",
"libc",
"untrusted",
"windows-sys 0.52.0",
]
[[package]]
name = "rustc-hash"
version = "2.1.1"
@@ -3654,53 +3311,6 @@ dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "rustls"
version = "0.23.37"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "758025cb5fccfd3bc2fd74708fd4682be41d99e5dff73c377c0646c6012c73a4"
dependencies = [
"aws-lc-rs",
"log",
"once_cell",
"ring",
"rustls-pki-types",
"rustls-webpki",
"subtle",
"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"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "be040f8b0a225e40375822a563fa9524378b9d63112f53e19ffff34df5d33fdd"
dependencies = [
"web-time",
"zeroize",
]
[[package]]
name = "rustls-webpki"
version = "0.103.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "df33b2b81ac578cabaf06b89b0631153a3f416b0a886e8a7a1707fb51abbd1ef"
dependencies = [
"aws-lc-rs",
"ring",
"rustls-pki-types",
"untrusted",
]
[[package]]
name = "rustversion"
version = "1.0.22"
@@ -3892,17 +3502,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"
@@ -3932,18 +3531,6 @@ dependencies = [
"serde_core",
]
[[package]]
name = "serde_urlencoded"
version = "0.7.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd"
dependencies = [
"form_urlencoded",
"itoa",
"ryu",
"serde",
]
[[package]]
name = "serde_with"
version = "3.18.0"
@@ -4205,12 +3792,6 @@ version = "0.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
[[package]]
name = "subtle"
version = "2.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292"
[[package]]
name = "swift-rs"
version = "1.0.7"
@@ -4362,7 +3943,7 @@ dependencies = [
"percent-encoding",
"plist",
"raw-window-handle",
"reqwest 0.13.2",
"reqwest",
"serde",
"serde_json",
"serde_repr",
@@ -4786,21 +4367,6 @@ dependencies = [
"zerovec",
]
[[package]]
name = "tinyvec"
version = "1.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3e61e67053d25a4e82c844e8424039d9745781b3fc4f32b8d55ed50f5f667ef3"
dependencies = [
"tinyvec_macros",
]
[[package]]
name = "tinyvec_macros"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20"
[[package]]
name = "tokio"
version = "1.50.0"
@@ -4812,31 +4378,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"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61"
dependencies = [
"rustls",
"tokio",
]
[[package]]
name = "tokio-util"
version = "0.7.18"
@@ -4968,7 +4512,6 @@ dependencies = [
"tokio",
"tower-layer",
"tower-service",
"tracing",
]
[[package]]
@@ -5007,7 +4550,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",
@@ -5149,12 +4691,6 @@ version = "0.2.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "673aac59facbab8a9007c7f6108d11f63b603f7cabff99fabf650fea5c32b861"
[[package]]
name = "untrusted"
version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1"
[[package]]
name = "url"
version = "2.5.8"
@@ -5366,19 +4902,6 @@ dependencies = [
"wasmparser",
]
[[package]]
name = "wasm-streams"
version = "0.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "15053d8d85c7eccdbefef60f06769760a563c7f0a9d6902a13d35c7800b0ad65"
dependencies = [
"futures-util",
"js-sys",
"wasm-bindgen",
"wasm-bindgen-futures",
"web-sys",
]
[[package]]
name = "wasm-streams"
version = "0.5.0"
@@ -5414,16 +4937,6 @@ dependencies = [
"wasm-bindgen",
]
[[package]]
name = "web-time"
version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb"
dependencies = [
"js-sys",
"wasm-bindgen",
]
[[package]]
name = "web_atoms"
version = "0.2.3"
@@ -5480,15 +4993,6 @@ dependencies = [
"system-deps",
]
[[package]]
name = "webpki-roots"
version = "1.0.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "22cfaf3c063993ff62e73cb4311efde4db1efb31ab78a3e5c457939ad5cc0bed"
dependencies = [
"rustls-pki-types",
]
[[package]]
name = "webview2-com"
version = "0.38.2"
@@ -5782,15 +5286,6 @@ dependencies = [
"windows-targets 0.48.5",
]
[[package]]
name = "windows-sys"
version = "0.52.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d"
dependencies = [
"windows-targets 0.52.6",
]
[[package]]
name = "windows-sys"
version = "0.59.0"
@@ -6432,12 +5927,6 @@ dependencies = [
"synstructure",
]
[[package]]
name = "zeroize"
version = "1.8.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0"
[[package]]
name = "zerotrie"
version = "0.2.3"

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

@@ -12,20 +12,11 @@ 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"
@@ -37,7 +28,4 @@ url = "2"
tauri-plugin-notification = "2"
[target.'cfg(windows)'.dependencies]
windows-sys = { version = "0.59", features = ["Win32_Foundation", "Win32_Security_Cryptography", "Win32_UI_Shell", "Win32_Security", "Win32_System_JobObjects"] }
[target.'cfg(target_os = "linux")'.dependencies]
webkit2gtk = "2.0.2"
windows-sys = { version = "0.59", features = ["Win32_Foundation", "Win32_UI_Shell", "Win32_Security", "Win32_System_JobObjects"] }

File diff suppressed because it is too large Load Diff

View File

@@ -1,248 +0,0 @@
use base64::Engine;
use std::env;
use std::fs;
use std::path::{Path, PathBuf};
const DEFAULT_CONFIG_PATH: &str = "~/.config/codenomad/config.json";
const TLS_DIR_NAME: &str = "tls";
const CA_CERT_FILE: &str = "ca-cert.pem";
const SERVER_CERT_FILE: &str = "server-cert.pem";
const SERVER_KEY_FILE: &str = "server-key.pem";
const TRUSTED_MARKER: &str = "server-ca.trusted";
#[cfg(windows)]
const WINDOWS_APP_USER_MODEL_ID: &str = "ai.neuralnomads.codenomad.client";
/// Holds the PEM-encoded certificate/key pair used by the local HTTPS proxy,
/// plus the CA certificate DER used for trust-store installation.
pub struct LocalCert {
pub cert_pem: String,
pub key_pem: String,
pub ca_cert_der: Vec<u8>,
}
struct TlsAssetPaths {
cert_path: PathBuf,
key_path: PathBuf,
trust_path: PathBuf,
append_ca_to_cert: bool,
}
/// Loads the TLS assets already managed by `packages/server`.
pub fn ensure_local_cert() -> Result<LocalCert, String> {
let assets = resolve_tls_asset_paths()?;
let mut cert_pem = read_pem_file(&assets.cert_path)?;
let key_pem = read_pem_file(&assets.key_path)?;
let trust_pem = read_pem_file(&assets.trust_path)?;
if assets.append_ca_to_cert {
cert_pem = format!("{}\n{}\n", cert_pem.trim(), trust_pem.trim());
}
let ca_cert_der = pem_to_der(&trust_pem)?;
Ok(LocalCert {
cert_pem,
key_pem,
ca_cert_der,
})
}
fn read_pem_file(path: &Path) -> Result<String, String> {
fs::read_to_string(path).map_err(|e| format!("Failed to read {}: {e}", path.display()))
}
fn server_tls_dir() -> Result<PathBuf, String> {
Ok(resolve_server_config_base_dir()?.join(TLS_DIR_NAME))
}
fn resolve_tls_asset_paths() -> Result<TlsAssetPaths, String> {
let tls_key_path = env::var("CLI_TLS_KEY")
.ok()
.filter(|value| !value.trim().is_empty())
.map(|value| resolve_path_like_server(&value))
.transpose()?;
let tls_cert_path = env::var("CLI_TLS_CERT")
.ok()
.filter(|value| !value.trim().is_empty())
.map(|value| resolve_path_like_server(&value))
.transpose()?;
let tls_ca_path = env::var("CLI_TLS_CA")
.ok()
.filter(|value| !value.trim().is_empty())
.map(|value| resolve_path_like_server(&value))
.transpose()?;
match (tls_key_path, tls_cert_path) {
(Some(key_path), Some(cert_path)) => {
let append_ca_to_cert = tls_ca_path.is_some();
let trust_path = tls_ca_path.unwrap_or_else(|| cert_path.clone());
Ok(TlsAssetPaths {
cert_path,
key_path,
trust_path,
append_ca_to_cert,
})
}
(Some(_), None) | (None, Some(_)) => Err(
"CLI_TLS_KEY and CLI_TLS_CERT must both be set when using custom TLS files"
.to_string(),
),
(None, None) => {
let tls_dir = server_tls_dir()?;
Ok(TlsAssetPaths {
cert_path: tls_dir.join(SERVER_CERT_FILE),
key_path: tls_dir.join(SERVER_KEY_FILE),
trust_path: tls_dir.join(CA_CERT_FILE),
append_ca_to_cert: true,
})
}
}
}
fn resolve_server_config_base_dir() -> Result<PathBuf, String> {
let raw = env::var("CLI_CONFIG")
.ok()
.filter(|value| !value.trim().is_empty())
.unwrap_or_else(|| DEFAULT_CONFIG_PATH.to_string());
let expanded = resolve_path_like_server(&raw)?;
let lower = raw.trim().to_lowercase();
if lower.ends_with(".yaml") || lower.ends_with(".yml") || lower.ends_with(".json") {
return expanded
.parent()
.map(Path::to_path_buf)
.ok_or_else(|| format!("Failed to determine config base dir from {}", expanded.display()));
}
Ok(expanded)
}
fn resolve_path_like_server(path: &str) -> Result<PathBuf, String> {
if path.starts_with("~/") {
let home = dirs::home_dir().or_else(|| env::var("HOME").ok().map(PathBuf::from));
let home = home.ok_or_else(|| "Cannot determine home directory".to_string())?;
return Ok(home.join(path.trim_start_matches("~/")));
}
let path = PathBuf::from(path);
if path.is_absolute() {
return Ok(path);
}
let cwd = env::current_dir().map_err(|e| format!("Failed to read current dir: {e}"))?;
Ok(cwd.join(path))
}
fn trusted_marker_path() -> Result<PathBuf, String> {
let base = dirs::data_local_dir()
.ok_or_else(|| "Cannot determine local app data directory".to_string())?;
#[cfg(windows)]
{
return Ok(base.join(WINDOWS_APP_USER_MODEL_ID).join(TRUSTED_MARKER));
}
#[cfg(not(windows))]
{
Ok(base.join("codenomad").join(TRUSTED_MARKER))
}
}
fn trusted_marker_value(cert_der: &[u8]) -> String {
cert_der.iter().map(|byte| format!("{byte:02x}")).collect()
}
fn has_matching_trusted_marker(cert_der: &[u8]) -> bool {
trusted_marker_path()
.ok()
.and_then(|path| fs::read_to_string(path).ok())
.map(|value| value.trim() == trusted_marker_value(cert_der))
.unwrap_or(false)
}
fn write_trusted_marker(cert_der: &[u8]) -> Result<(), String> {
let path = trusted_marker_path()?;
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)
.map_err(|e| format!("Failed to create trust state dir {}: {e}", parent.display()))?;
}
fs::write(path, trusted_marker_value(cert_der))
.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 trust_cert_in_store(cert_der: &[u8]) -> Result<(), String> {
use windows_sys::Win32::Security::Cryptography::{
CertAddEncodedCertificateToStore, CertCloseStore, CertOpenSystemStoreW,
CERT_STORE_ADD_REPLACE_EXISTING, PKCS_7_ASN_ENCODING, X509_ASN_ENCODING,
};
if has_matching_trusted_marker(cert_der) {
return Ok(());
}
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 encoding = X509_ASN_ENCODING | PKCS_7_ASN_ENCODING;
let result = CertAddEncodedCertificateToStore(
store,
encoding,
cert_der.as_ptr(),
cert_der.len() as u32,
CERT_STORE_ADD_REPLACE_EXISTING,
std::ptr::null_mut(),
);
CertCloseStore(store, 0);
if result == 0 {
return Err(
"Failed to add certificate to trust store. The user may have declined the security dialog."
.into(),
);
}
}
write_trusted_marker(cert_der)?;
Ok(())
}
#[cfg(not(windows))]
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(())
}
fn pem_to_der(pem: &str) -> Result<Vec<u8>, String> {
let mut body = String::new();
let mut in_block = false;
for line in pem.lines() {
if line.starts_with("-----BEGIN CERTIFICATE-----") {
in_block = true;
continue;
}
if line.starts_with("-----END CERTIFICATE-----") {
break;
}
if in_block {
body.push_str(line.trim());
}
}
if body.is_empty() {
return Err("No certificate found in PEM file".to_string());
}
base64::engine::general_purpose::STANDARD
.decode(body)
.map_err(|e| format!("Failed to decode certificate PEM: {e}"))
}

View File

@@ -1,88 +0,0 @@
use crate::AppState;
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 ensure_remote_window_tls_handler(
window: &WebviewWindow,
app_handle: &AppHandle,
window_label: &str,
) -> Result<(), String> {
{
let state = app_handle.state::<AppState>();
let mut handlers = state
.remote_tls_handlers
.lock()
.map_err(|err| err.to_string())?;
if !handlers.insert(window_label.to_string()) {
return Ok(());
}
}
let app_handle = app_handle.clone();
let window_label = window_label.to_string();
window
.with_webview(move |platform_webview| {
let webview = platform_webview.inner();
let app_handle = app_handle.clone();
let window_label = window_label.clone();
webview.connect_load_failed_with_tls_errors(move |view, failing_uri, certificate, _| {
allow_remote_tls_certificate(
&app_handle,
&window_label,
view,
failing_uri,
certificate,
)
});
})
.map_err(|err| err.to_string())
}
fn allow_remote_tls_certificate(
app_handle: &AppHandle,
window_label: &str,
view: &WebView,
failing_uri: &str,
certificate: &webkit2gtk::gio::TlsCertificate,
) -> bool {
let Ok(parsed_uri) = Url::parse(failing_uri) else {
return false;
};
let Some(host) = parsed_uri.host_str() else {
return false;
};
let state = app_handle.state::<AppState>();
let skip_tls_verify = state
.remote_skip_tls_verify
.lock()
.ok()
.and_then(|values| values.get(window_label).copied())
.unwrap_or(false);
if !skip_tls_verify {
return false;
}
let expected_origin = state
.remote_origins
.lock()
.ok()
.and_then(|origins| origins.get(window_label).cloned());
let parsed_origin = parsed_uri.origin().ascii_serialization();
if expected_origin.as_deref() != Some(parsed_origin.as_str()) {
return false;
}
let Some(context) = view.context() else {
return false;
};
context.allow_tls_certificate_for_host(certificate, host);
view.load_uri(failing_uri);
true
}

View File

@@ -1,16 +1,12 @@
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
#[allow(dead_code)]
mod cert_manager;
mod cli_manager;
#[cfg(target_os = "linux")]
mod linux_tls;
use cli_manager::{CliProcessManager, CliStatus};
use keepawake::KeepAwake;
use serde::Deserialize;
use serde_json::json;
use std::collections::{HashMap, HashSet};
use std::collections::HashMap;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Mutex;
use std::time::{SystemTime, UNIX_EPOCH};
@@ -49,8 +45,6 @@ pub struct AppState {
pub wake_lock: Mutex<Option<KeepAwake>>,
pub zoom_level: Mutex<f64>,
pub remote_origins: Mutex<HashMap<String, String>>,
pub remote_skip_tls_verify: Mutex<HashMap<String, bool>>,
pub remote_tls_handlers: Mutex<HashSet<String>>,
}
#[derive(Debug, Deserialize)]
@@ -59,8 +53,6 @@ struct RemoteWindowPayload {
id: String,
name: String,
base_url: String,
entry_url: Option<String>,
#[allow(dead_code)]
skip_tls_verify: bool,
}
@@ -127,7 +119,7 @@ fn is_dev_mode() -> bool {
fn should_allow_internal(url: &Url) -> bool {
match url.scheme() {
"tauri" | "asset" | "file" | "about" => true,
"tauri" | "asset" | "file" => true,
// On Windows/WebView2, Tauri serves the app assets from `tauri.localhost`.
// This must be treated as an internal origin or the navigation guard will
// redirect it to the system browser and the app will appear blank.
@@ -175,40 +167,25 @@ fn intercept_navigation<R: Runtime>(webview: &Webview<R>, url: &Url) -> bool {
false
}
async fn open_remote_window_impl(
app: AppHandle,
payload: RemoteWindowPayload,
) -> Result<(), String> {
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())?;
#[tauri::command]
fn open_remote_window(app: AppHandle, payload: RemoteWindowPayload) -> Result<(), String> {
if payload.skip_tls_verify && payload.base_url.starts_with("https://") {
return Err(
"Tauri cannot bypass self-signed HTTPS certificates automatically yet. Trust the certificate in your OS first, then reconnect, or use the CodeNomad Electron app."
.to_string(),
);
}
let parsed = Url::parse(&payload.base_url).map_err(|err| err.to_string())?;
let label = format!("remote-{}", payload.id);
let title = format!(
"{} - {}",
payload.name,
Url::parse(&payload.base_url)
.ok()
.and_then(|url| url.host_str().map(str::to_string))
.unwrap_or_else(|| payload.base_url.clone())
parsed.host_str().unwrap_or(payload.base_url.as_str())
);
let window_url = parsed.clone();
app.state::<AppState>()
.remote_origins
.lock()
.map_err(|err| err.to_string())?
.insert(label.clone(), window_url.origin().ascii_serialization());
app.state::<AppState>()
.remote_skip_tls_verify
.lock()
.map_err(|err| err.to_string())?
.insert(label.clone(), parsed.scheme() == "https");
if let Some(existing) = app.get_webview_window(&label) {
#[cfg(target_os = "linux")]
linux_tls::ensure_remote_window_tls_handler(&existing, &app, &label)?;
let _ = existing.navigate(window_url.clone());
let _ = existing.navigate(parsed.clone());
let _ = existing.set_title(&title);
let _ = existing.show();
let _ = existing.unminimize();
@@ -216,44 +193,25 @@ async fn open_remote_window_impl(
return Ok(());
}
#[cfg(target_os = "linux")]
let initial_url = if linux_tls::should_bootstrap_tls_navigation(&window_url, payload.skip_tls_verify)
{
Url::parse("about:blank").map_err(|err| err.to_string())?
} else {
window_url.clone()
};
app.state::<AppState>()
.remote_origins
.lock()
.map_err(|err| err.to_string())?
.insert(label.clone(), parsed.origin().ascii_serialization());
#[cfg(not(target_os = "linux"))]
let initial_url = window_url.clone();
let window = WebviewWindowBuilder::new(&app, label.clone(), WebviewUrl::External(initial_url.clone()))
.title(title)
.inner_size(1400.0, 900.0)
.min_inner_size(800.0, 600.0)
.build()
.map_err(|err| err.to_string())?;
#[cfg(target_os = "linux")]
{
linux_tls::ensure_remote_window_tls_handler(&window, &app, &label)?;
if initial_url != window_url {
let _ = window.navigate(window_url.clone());
}
}
let window =
WebviewWindowBuilder::new(&app, label.clone(), WebviewUrl::External(parsed.clone()))
.title(title)
.inner_size(1400.0, 900.0)
.min_inner_size(800.0, 600.0)
.build()
.map_err(|err| err.to_string())?;
let app_handle = app.clone();
let label_for_cleanup = label.clone();
window.on_window_event(move |event| {
if let WindowEvent::Destroyed = event {
if let Ok(mut origins) = app_handle.state::<AppState>().remote_origins.lock() {
origins.remove(&label_for_cleanup);
}
if let Ok(mut values) = app_handle.state::<AppState>().remote_skip_tls_verify.lock() {
values.remove(&label_for_cleanup);
}
if let Ok(mut handlers) = app_handle.state::<AppState>().remote_tls_handlers.lock() {
handlers.remove(&label_for_cleanup);
origins.remove(&label);
}
}
});
@@ -261,29 +219,6 @@ async fn open_remote_window_impl(
Ok(())
}
#[tauri::command]
async fn open_remote_window(app: AppHandle, payload: RemoteWindowPayload) -> Result<(), String> {
#[cfg(not(target_os = "linux"))]
{
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" {
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 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}"
));
}
}
}
open_remote_window_impl(app, payload).await
}
fn collect_directory_paths(paths: &[std::path::PathBuf]) -> Vec<String> {
paths
.iter()
@@ -411,8 +346,6 @@ fn set_windows_app_user_model_id() {
fn set_windows_app_user_model_id() {}
fn main() {
let _ = rustls::crypto::ring::default_provider().install_default();
let navigation_guard: TauriPlugin<Wry, ()> = PluginBuilder::new("external-link-guard")
.on_navigation(|webview, url| intercept_navigation(webview, url))
.build();
@@ -440,8 +373,6 @@ fn main() {
wake_lock: Mutex::new(None),
zoom_level: Mutex::new(DEFAULT_ZOOM_LEVEL),
remote_origins: Mutex::new(HashMap::new()),
remote_skip_tls_verify: Mutex::new(HashMap::new()),
remote_tls_handlers: Mutex::new(HashSet::new()),
})
.setup(|app| {
set_windows_app_user_model_id();

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

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

@@ -16,7 +16,6 @@ import { showAlertDialog } from "../stores/alerts"
import { openSettings, settingsOpen } from "../stores/settings-screen"
import { openExternalUrl } from "../lib/external-url"
import { serverApi } from "../lib/api-client"
import { runtimeEnv } from "../lib/runtime-env"
import { openRemoteServerWindow } from "../lib/native/remote-window"
const codeNomadLogo = new URL("../images/CodeNomad-Icon.png", import.meta.url).href
@@ -333,15 +332,7 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
})
if (openWindow) {
const windowUrl =
runtimeEnv.host === "tauri"
? (await serverApi.createRemoteProxySession({
baseUrl: profile.baseUrl,
skipTlsVerify: profile.skipTlsVerify,
})).windowUrl
: undefined
await openRemoteServerWindow(profile, windowUrl)
await openRemoteServerWindow(profile)
await markRemoteServerConnected(profile.id)
}

View File

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

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

@@ -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"])
@@ -375,11 +374,11 @@ 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
) {
if (exceedsViewport && relativeTop < 0) {
const alignDelta = relativeTop - holdTargetTopThresholdPx()
if (Math.abs(alignDelta) > 1) {
element.scrollTop = Math.max(0, element.scrollTop + alignDelta)
}
setHeldItemCount(itemCount)
}
}

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

@@ -12,8 +12,6 @@ import type {
SpeechTranscriptionResponse,
SideCar,
ServerMeta,
RemoteProxySessionCreateRequest,
RemoteProxySessionCreateResponse,
RemoteServerProbeRequest,
RemoteServerProbeResponse,
VoiceModeStateResponse,
@@ -258,12 +256,6 @@ export const serverApi = {
body: JSON.stringify(payload),
})
},
createRemoteProxySession(payload: RemoteProxySessionCreateRequest): Promise<RemoteProxySessionCreateResponse> {
return request<RemoteProxySessionCreateResponse>("/api/remote-proxy/sessions", {
method: "POST",
body: JSON.stringify(payload),
})
},
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

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

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

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