Compare commits
36 Commits
v0.13.3-de
...
v0.13.3-de
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e82e529a8f | ||
|
|
4f236ce36f | ||
|
|
2ffeb45a9c | ||
|
|
df16b64a95 | ||
|
|
f3c54df283 | ||
|
|
5658a9f62d | ||
|
|
278b563c1a | ||
|
|
27bccb8d6b | ||
|
|
153065d025 | ||
|
|
2abda0e6b4 | ||
|
|
800133361d | ||
|
|
034cb5dea9 | ||
|
|
d7ab84f245 | ||
|
|
201988b97c | ||
|
|
6a6fcff2c8 | ||
|
|
f29f197b9a | ||
|
|
dbde403b3e | ||
|
|
230c981cc2 | ||
|
|
34978c87fb | ||
|
|
3e6d0a402c | ||
|
|
e81c5f6443 | ||
|
|
b0d27bd127 | ||
|
|
7576470295 | ||
|
|
6d32e09db0 | ||
|
|
503cb3a02e | ||
|
|
0250c6350f | ||
|
|
24cc8fe939 | ||
|
|
282b234a7c | ||
|
|
4ba088a876 | ||
|
|
7b1817d606 | ||
|
|
5bc3c23ec5 | ||
|
|
127a51e3c3 | ||
|
|
daa22b6d8c | ||
|
|
23f2de2d7e | ||
|
|
80c9b76709 | ||
|
|
a29b77d60b |
5
.github/workflows/comment-pr-artifacts.yml
vendored
5
.github/workflows/comment-pr-artifacts.yml
vendored
@@ -4,6 +4,7 @@ on:
|
|||||||
pull_request_target:
|
pull_request_target:
|
||||||
types:
|
types:
|
||||||
- opened
|
- opened
|
||||||
|
- edited
|
||||||
- synchronize
|
- synchronize
|
||||||
- reopened
|
- reopened
|
||||||
- ready_for_review
|
- ready_for_review
|
||||||
@@ -19,7 +20,7 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
env:
|
env:
|
||||||
ALLOWED_ACTORS: ${{ vars.ALLOWED_NON_DEV_PR_ACTORS }}
|
ALLOWED_ACTORS: ${{ vars.ALLOWED_NON_DEV_PR_ACTORS }}
|
||||||
ACTOR: ${{ github.actor }}
|
PR_AUTHOR: ${{ github.event.pull_request.user.login }}
|
||||||
BASE_REF: ${{ github.event.pull_request.base.ref }}
|
BASE_REF: ${{ github.event.pull_request.base.ref }}
|
||||||
IS_DRAFT: ${{ github.event.pull_request.draft }}
|
IS_DRAFT: ${{ github.event.pull_request.draft }}
|
||||||
PR_NUMBER: ${{ github.event.pull_request.number }}
|
PR_NUMBER: ${{ github.event.pull_request.number }}
|
||||||
@@ -37,7 +38,7 @@ jobs:
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
normalized=",${ALLOWED_ACTORS},"
|
normalized=",${ALLOWED_ACTORS},"
|
||||||
if [[ "$normalized" == *",${ACTOR},"* ]]; then
|
if [[ "$normalized" == *",${PR_AUTHOR},"* ]]; then
|
||||||
echo "allowed=true" >> "$GITHUB_OUTPUT"
|
echo "allowed=true" >> "$GITHUB_OUTPUT"
|
||||||
else
|
else
|
||||||
echo "allowed=false" >> "$GITHUB_OUTPUT"
|
echo "allowed=false" >> "$GITHUB_OUTPUT"
|
||||||
|
|||||||
7
.github/workflows/pr-build.yml
vendored
7
.github/workflows/pr-build.yml
vendored
@@ -4,6 +4,7 @@ on:
|
|||||||
pull_request:
|
pull_request:
|
||||||
types:
|
types:
|
||||||
- opened
|
- opened
|
||||||
|
- edited
|
||||||
- synchronize
|
- synchronize
|
||||||
- reopened
|
- reopened
|
||||||
- ready_for_review
|
- ready_for_review
|
||||||
@@ -23,7 +24,7 @@ jobs:
|
|||||||
allowed: ${{ steps.auth.outputs.allowed }}
|
allowed: ${{ steps.auth.outputs.allowed }}
|
||||||
env:
|
env:
|
||||||
ALLOWED_ACTORS: ${{ vars.ALLOWED_NON_DEV_PR_ACTORS }}
|
ALLOWED_ACTORS: ${{ vars.ALLOWED_NON_DEV_PR_ACTORS }}
|
||||||
ACTOR: ${{ github.actor }}
|
PR_AUTHOR: ${{ github.event.pull_request.user.login }}
|
||||||
BASE_REF: ${{ github.event.pull_request.base.ref }}
|
BASE_REF: ${{ github.event.pull_request.base.ref }}
|
||||||
steps:
|
steps:
|
||||||
- name: Check PR authorization
|
- name: Check PR authorization
|
||||||
@@ -37,11 +38,11 @@ jobs:
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
normalized=",${ALLOWED_ACTORS},"
|
normalized=",${ALLOWED_ACTORS},"
|
||||||
if [[ "$normalized" == *",${ACTOR},"* ]]; then
|
if [[ "$normalized" == *",${PR_AUTHOR},"* ]]; then
|
||||||
echo "allowed=true" >> "$GITHUB_OUTPUT"
|
echo "allowed=true" >> "$GITHUB_OUTPUT"
|
||||||
else
|
else
|
||||||
echo "allowed=false" >> "$GITHUB_OUTPUT"
|
echo "allowed=false" >> "$GITHUB_OUTPUT"
|
||||||
echo "Skipping builds for unauthorized PR targeting $BASE_REF" >&2
|
echo "Skipping builds for PR by unauthorized author targeting $BASE_REF" >&2
|
||||||
fi
|
fi
|
||||||
|
|
||||||
build:
|
build:
|
||||||
|
|||||||
7
.github/workflows/restrict-non-dev-prs.yml
vendored
7
.github/workflows/restrict-non-dev-prs.yml
vendored
@@ -4,6 +4,7 @@ on:
|
|||||||
pull_request_target:
|
pull_request_target:
|
||||||
types:
|
types:
|
||||||
- opened
|
- opened
|
||||||
|
- edited
|
||||||
- reopened
|
- reopened
|
||||||
- synchronize
|
- synchronize
|
||||||
|
|
||||||
@@ -17,7 +18,7 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
env:
|
env:
|
||||||
ALLOWED_ACTORS: ${{ vars.ALLOWED_NON_DEV_PR_ACTORS }}
|
ALLOWED_ACTORS: ${{ vars.ALLOWED_NON_DEV_PR_ACTORS }}
|
||||||
ACTOR: ${{ github.actor }}
|
PR_AUTHOR: ${{ github.event.pull_request.user.login }}
|
||||||
PR_NUMBER: ${{ github.event.pull_request.number }}
|
PR_NUMBER: ${{ github.event.pull_request.number }}
|
||||||
BASE_REF: ${{ github.event.pull_request.base.ref }}
|
BASE_REF: ${{ github.event.pull_request.base.ref }}
|
||||||
steps:
|
steps:
|
||||||
@@ -27,7 +28,7 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
normalized=",${ALLOWED_ACTORS},"
|
normalized=",${ALLOWED_ACTORS},"
|
||||||
if [[ "$normalized" == *",${ACTOR},"* ]]; then
|
if [[ "$normalized" == *",${PR_AUTHOR},"* ]]; then
|
||||||
echo "authorized=true" >> "$GITHUB_OUTPUT"
|
echo "authorized=true" >> "$GITHUB_OUTPUT"
|
||||||
else
|
else
|
||||||
echo "authorized=false" >> "$GITHUB_OUTPUT"
|
echo "authorized=false" >> "$GITHUB_OUTPUT"
|
||||||
@@ -50,5 +51,5 @@ jobs:
|
|||||||
- name: Fail unauthorized PR
|
- name: Fail unauthorized PR
|
||||||
if: ${{ steps.auth.outputs.authorized != 'true' }}
|
if: ${{ steps.auth.outputs.authorized != 'true' }}
|
||||||
run: |
|
run: |
|
||||||
echo "Actor $ACTOR is not allowed to open PRs targeting $BASE_REF" >&2
|
echo "PR author $PR_AUTHOR is not allowed to open PRs targeting $BASE_REF" >&2
|
||||||
exit 1
|
exit 1
|
||||||
|
|||||||
@@ -328,7 +328,6 @@ function finalizeCliSwap(url: string) {
|
|||||||
mainWindow.loadURL(url).catch((error) => console.error("[cli] failed to load CLI view:", error))
|
mainWindow.loadURL(url).catch((error) => console.error("[cli] failed to load CLI view:", error))
|
||||||
}
|
}
|
||||||
|
|
||||||
const SESSION_COOKIE_NAME = "codenomad_session"
|
|
||||||
let bootstrapExchangeInFlight = false
|
let bootstrapExchangeInFlight = false
|
||||||
|
|
||||||
function extractCookieValue(setCookieHeader: string | string[] | undefined, name: string): string | null {
|
function extractCookieValue(setCookieHeader: string | string[] | undefined, name: string): string | null {
|
||||||
@@ -351,6 +350,7 @@ function extractCookieValue(setCookieHeader: string | string[] | undefined, name
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function exchangeBootstrapToken(baseUrl: string, token: string): Promise<boolean> {
|
async function exchangeBootstrapToken(baseUrl: string, token: string): Promise<boolean> {
|
||||||
|
const sessionCookieName = cliManager.getAuthCookieName()
|
||||||
const target = new URL("/api/auth/token", baseUrl)
|
const target = new URL("/api/auth/token", baseUrl)
|
||||||
const body = JSON.stringify({ token })
|
const body = JSON.stringify({ token })
|
||||||
|
|
||||||
@@ -381,14 +381,14 @@ async function exchangeBootstrapToken(baseUrl: string, token: string): Promise<b
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
const sessionId = extractCookieValue(result.setCookie, SESSION_COOKIE_NAME)
|
const sessionId = extractCookieValue(result.setCookie, sessionCookieName)
|
||||||
if (!sessionId) {
|
if (!sessionId) {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
await session.defaultSession.cookies.set({
|
await session.defaultSession.cookies.set({
|
||||||
url: baseUrl,
|
url: baseUrl,
|
||||||
name: SESSION_COOKIE_NAME,
|
name: sessionCookieName,
|
||||||
value: sessionId,
|
value: sessionId,
|
||||||
httpOnly: true,
|
httpOnly: true,
|
||||||
path: "/",
|
path: "/",
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ const mainFilename = fileURLToPath(import.meta.url)
|
|||||||
const mainDirname = path.dirname(mainFilename)
|
const mainDirname = path.dirname(mainFilename)
|
||||||
|
|
||||||
const BOOTSTRAP_TOKEN_PREFIX = "CODENOMAD_BOOTSTRAP_TOKEN:"
|
const BOOTSTRAP_TOKEN_PREFIX = "CODENOMAD_BOOTSTRAP_TOKEN:"
|
||||||
|
const SESSION_COOKIE_NAME_PREFIX = "codenomad_session"
|
||||||
|
|
||||||
type CliState = "starting" | "ready" | "error" | "stopped"
|
type CliState = "starting" | "ready" | "error" | "stopped"
|
||||||
type ListeningMode = "local" | "all"
|
type ListeningMode = "local" | "all"
|
||||||
@@ -129,6 +130,7 @@ export class CliProcessManager extends EventEmitter {
|
|||||||
private stdoutBuffer = ""
|
private stdoutBuffer = ""
|
||||||
private stderrBuffer = ""
|
private stderrBuffer = ""
|
||||||
private bootstrapToken: string | null = null
|
private bootstrapToken: string | null = null
|
||||||
|
private authCookieName = `${SESSION_COOKIE_NAME_PREFIX}_${process.pid}_${Date.now()}`
|
||||||
private requestedStop = false
|
private requestedStop = false
|
||||||
|
|
||||||
async start(options: StartOptions): Promise<CliStatus> {
|
async start(options: StartOptions): Promise<CliStatus> {
|
||||||
@@ -139,6 +141,7 @@ export class CliProcessManager extends EventEmitter {
|
|||||||
this.stdoutBuffer = ""
|
this.stdoutBuffer = ""
|
||||||
this.stderrBuffer = ""
|
this.stderrBuffer = ""
|
||||||
this.bootstrapToken = null
|
this.bootstrapToken = null
|
||||||
|
this.authCookieName = `${SESSION_COOKIE_NAME_PREFIX}_${process.pid}_${Date.now()}`
|
||||||
this.requestedStop = false
|
this.requestedStop = false
|
||||||
this.updateStatus({ state: "starting", port: undefined, pid: undefined, url: undefined, error: undefined })
|
this.updateStatus({ state: "starting", port: undefined, pid: undefined, url: undefined, error: undefined })
|
||||||
|
|
||||||
@@ -436,6 +439,10 @@ export class CliProcessManager extends EventEmitter {
|
|||||||
return { ...this.status }
|
return { ...this.status }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
getAuthCookieName(): string {
|
||||||
|
return this.authCookieName
|
||||||
|
}
|
||||||
|
|
||||||
private resolveListeningMode(): ListeningMode {
|
private resolveListeningMode(): ListeningMode {
|
||||||
return readListeningModeFromConfig()
|
return readListeningModeFromConfig()
|
||||||
}
|
}
|
||||||
@@ -532,7 +539,7 @@ export class CliProcessManager extends EventEmitter {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private buildCliArgs(options: StartOptions, host: string): string[] {
|
private buildCliArgs(options: StartOptions, host: string): string[] {
|
||||||
const args = ["serve", "--host", host, "--generate-token"]
|
const args = ["serve", "--host", host, "--generate-token", "--auth-cookie-name", this.authCookieName]
|
||||||
|
|
||||||
if (options.dev) {
|
if (options.dev) {
|
||||||
// Dev: run plain HTTP + Vite dev server proxy.
|
// Dev: run plain HTTP + Vite dev server proxy.
|
||||||
|
|||||||
@@ -16,16 +16,18 @@ export interface AuthManagerInit {
|
|||||||
password?: string
|
password?: string
|
||||||
generateToken: boolean
|
generateToken: boolean
|
||||||
dangerouslySkipAuth?: boolean
|
dangerouslySkipAuth?: boolean
|
||||||
|
cookieName?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export class AuthManager {
|
export class AuthManager {
|
||||||
private readonly authStore: AuthStore | null
|
private readonly authStore: AuthStore | null
|
||||||
private readonly tokenManager: TokenManager | null
|
private readonly tokenManager: TokenManager | null
|
||||||
private readonly sessionManager = new SessionManager()
|
private readonly sessionManager = new SessionManager()
|
||||||
private readonly cookieName = DEFAULT_AUTH_COOKIE_NAME
|
private readonly cookieName: string
|
||||||
private readonly authEnabled: boolean
|
private readonly authEnabled: boolean
|
||||||
|
|
||||||
constructor(private readonly init: AuthManagerInit, private readonly logger: Logger) {
|
constructor(private readonly init: AuthManagerInit, private readonly logger: Logger) {
|
||||||
|
this.cookieName = sanitizeCookieName(init.cookieName)
|
||||||
this.authEnabled = !Boolean(init.dangerouslySkipAuth)
|
this.authEnabled = !Boolean(init.dangerouslySkipAuth)
|
||||||
|
|
||||||
if (!this.authEnabled) {
|
if (!this.authEnabled) {
|
||||||
@@ -139,6 +141,16 @@ export class AuthManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function sanitizeCookieName(value: string | undefined): string {
|
||||||
|
const trimmed = value?.trim()
|
||||||
|
if (!trimmed) {
|
||||||
|
return DEFAULT_AUTH_COOKIE_NAME
|
||||||
|
}
|
||||||
|
|
||||||
|
const sanitized = trimmed.replace(/[^A-Za-z0-9_-]/g, "_")
|
||||||
|
return sanitized.length > 0 ? sanitized : DEFAULT_AUTH_COOKIE_NAME
|
||||||
|
}
|
||||||
|
|
||||||
function resolveAuthFilePath(configPath: string) {
|
function resolveAuthFilePath(configPath: string) {
|
||||||
const resolvedConfigPath = resolvePath(configPath)
|
const resolvedConfigPath = resolvePath(configPath)
|
||||||
return path.join(path.dirname(resolvedConfigPath), "auth.json")
|
return path.join(path.dirname(resolvedConfigPath), "auth.json")
|
||||||
|
|||||||
@@ -19,9 +19,9 @@ import { InstanceEventBridge } from "./workspaces/instance-events"
|
|||||||
import { createLogger } from "./logger"
|
import { createLogger } from "./logger"
|
||||||
import { launchInBrowser } from "./launcher"
|
import { launchInBrowser } from "./launcher"
|
||||||
import { resolveUi } from "./ui/remote-ui"
|
import { resolveUi } from "./ui/remote-ui"
|
||||||
import { AuthManager, BOOTSTRAP_TOKEN_STDOUT_PREFIX, DEFAULT_AUTH_USERNAME } from "./auth/manager"
|
import { AuthManager, BOOTSTRAP_TOKEN_STDOUT_PREFIX, DEFAULT_AUTH_COOKIE_NAME, DEFAULT_AUTH_USERNAME } from "./auth/manager"
|
||||||
import { resolveHttpsOptions } from "./server/tls"
|
import { resolveHttpsOptions } from "./server/tls"
|
||||||
import { resolveNetworkAddresses } from "./server/network-addresses"
|
import { resolveNetworkAddresses, resolveRemoteAddresses } from "./server/network-addresses"
|
||||||
import { startDevReleaseMonitor } from "./releases/dev-release-monitor"
|
import { startDevReleaseMonitor } from "./releases/dev-release-monitor"
|
||||||
import { SpeechService } from "./speech/service"
|
import { SpeechService } from "./speech/service"
|
||||||
|
|
||||||
@@ -55,6 +55,7 @@ interface CliOptions {
|
|||||||
launch: boolean
|
launch: boolean
|
||||||
authUsername: string
|
authUsername: string
|
||||||
authPassword?: string
|
authPassword?: string
|
||||||
|
authCookieName: string
|
||||||
generateToken: boolean
|
generateToken: boolean
|
||||||
dangerouslySkipAuth: boolean
|
dangerouslySkipAuth: boolean
|
||||||
}
|
}
|
||||||
@@ -100,6 +101,11 @@ function parseCliOptions(argv: string[]): CliOptions {
|
|||||||
.default(DEFAULT_AUTH_USERNAME),
|
.default(DEFAULT_AUTH_USERNAME),
|
||||||
)
|
)
|
||||||
.addOption(new Option("--password <password>", "Password for server authentication").env("CODENOMAD_SERVER_PASSWORD"))
|
.addOption(new Option("--password <password>", "Password for server authentication").env("CODENOMAD_SERVER_PASSWORD"))
|
||||||
|
.addOption(
|
||||||
|
new Option("--auth-cookie-name <name>", "Cookie name for server authentication")
|
||||||
|
.env("CODENOMAD_AUTH_COOKIE_NAME")
|
||||||
|
.default(DEFAULT_AUTH_COOKIE_NAME),
|
||||||
|
)
|
||||||
.addOption(
|
.addOption(
|
||||||
new Option("--generate-token", "Emit a one-time bootstrap token for desktop")
|
new Option("--generate-token", "Emit a one-time bootstrap token for desktop")
|
||||||
.env("CODENOMAD_GENERATE_TOKEN")
|
.env("CODENOMAD_GENERATE_TOKEN")
|
||||||
@@ -139,6 +145,7 @@ function parseCliOptions(argv: string[]): CliOptions {
|
|||||||
launch?: boolean
|
launch?: boolean
|
||||||
username: string
|
username: string
|
||||||
password?: string
|
password?: string
|
||||||
|
authCookieName: string
|
||||||
generateToken?: boolean
|
generateToken?: boolean
|
||||||
dangerouslySkipAuth?: boolean
|
dangerouslySkipAuth?: boolean
|
||||||
}>()
|
}>()
|
||||||
@@ -185,6 +192,7 @@ function parseCliOptions(argv: string[]): CliOptions {
|
|||||||
launch: Boolean(parsed.launch),
|
launch: Boolean(parsed.launch),
|
||||||
authUsername: parsed.username,
|
authUsername: parsed.username,
|
||||||
authPassword: parsed.password,
|
authPassword: parsed.password,
|
||||||
|
authCookieName: parsed.authCookieName,
|
||||||
generateToken: Boolean(parsed.generateToken),
|
generateToken: Boolean(parsed.generateToken),
|
||||||
dangerouslySkipAuth: Boolean(parsed.dangerouslySkipAuth),
|
dangerouslySkipAuth: Boolean(parsed.dangerouslySkipAuth),
|
||||||
}
|
}
|
||||||
@@ -266,6 +274,7 @@ async function main() {
|
|||||||
configPath: configLocation.configYamlPath,
|
configPath: configLocation.configYamlPath,
|
||||||
username: options.authUsername,
|
username: options.authUsername,
|
||||||
password: options.authPassword,
|
password: options.authPassword,
|
||||||
|
cookieName: options.authCookieName,
|
||||||
generateToken: options.generateToken,
|
generateToken: options.generateToken,
|
||||||
dangerouslySkipAuth: options.dangerouslySkipAuth,
|
dangerouslySkipAuth: options.dangerouslySkipAuth,
|
||||||
},
|
},
|
||||||
@@ -442,18 +451,22 @@ async function main() {
|
|||||||
// which can lead clients to talk to the wrong process.
|
// which can lead clients to talk to the wrong process.
|
||||||
const localUrl = `${localProtocol}://127.0.0.1:${localStart.port}`
|
const localUrl = `${localProtocol}://127.0.0.1:${localStart.port}`
|
||||||
let remoteUrl: string | undefined
|
let remoteUrl: string | undefined
|
||||||
|
let remoteAddresses = [] as ReturnType<typeof resolveNetworkAddresses>
|
||||||
if (remoteStart) {
|
if (remoteStart) {
|
||||||
const wantsAll = options.host === "0.0.0.0" || !isLoopbackHost(options.host)
|
const wantsAll = options.host === "0.0.0.0" || !isLoopbackHost(options.host)
|
||||||
let remoteHost = options.host
|
let remoteHost = options.host
|
||||||
if (wantsAll) {
|
if (wantsAll) {
|
||||||
if (options.host === "0.0.0.0") {
|
if (options.host === "0.0.0.0") {
|
||||||
const candidates = resolveNetworkAddresses({ host: options.host, protocol: remoteProtocol, port: remoteStart.port })
|
const resolved = resolveRemoteAddresses({ host: options.host, protocol: remoteProtocol, port: remoteStart.port })
|
||||||
remoteHost = candidates.find((addr) => addr.scope === "external")?.ip ?? "localhost"
|
remoteAddresses = resolved.userVisible
|
||||||
|
remoteUrl = resolved.primaryRemoteUrl ?? `${remoteProtocol}://localhost:${remoteStart.port}`
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
remoteHost = "localhost"
|
remoteHost = "localhost"
|
||||||
}
|
}
|
||||||
remoteUrl = `${remoteProtocol}://${remoteHost}:${remoteStart.port}`
|
if (!remoteUrl) {
|
||||||
|
remoteUrl = `${remoteProtocol}://${remoteHost}:${remoteStart.port}`
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
serverMeta.localUrl = localUrl
|
serverMeta.localUrl = localUrl
|
||||||
@@ -464,7 +477,9 @@ async function main() {
|
|||||||
serverMeta.listeningMode = options.host === "0.0.0.0" || !isLoopbackHost(options.host) ? "all" : "local"
|
serverMeta.listeningMode = options.host === "0.0.0.0" || !isLoopbackHost(options.host) ? "all" : "local"
|
||||||
|
|
||||||
if (serverMeta.remotePort && remoteUrl) {
|
if (serverMeta.remotePort && remoteUrl) {
|
||||||
serverMeta.addresses = resolveNetworkAddresses({ host: options.host, protocol: remoteProtocol, port: serverMeta.remotePort })
|
serverMeta.addresses = remoteAddresses.length
|
||||||
|
? remoteAddresses
|
||||||
|
: resolveNetworkAddresses({ host: options.host, protocol: remoteProtocol, port: serverMeta.remotePort })
|
||||||
} else {
|
} else {
|
||||||
serverMeta.addresses = []
|
serverMeta.addresses = []
|
||||||
}
|
}
|
||||||
@@ -472,6 +487,16 @@ async function main() {
|
|||||||
console.log(`Local Connection URL : ${serverMeta.localUrl}`)
|
console.log(`Local Connection URL : ${serverMeta.localUrl}`)
|
||||||
if (serverMeta.remoteUrl) {
|
if (serverMeta.remoteUrl) {
|
||||||
console.log(`Remote Connection URL : ${serverMeta.remoteUrl}`)
|
console.log(`Remote Connection URL : ${serverMeta.remoteUrl}`)
|
||||||
|
const additionalRemoteUrls = serverMeta.addresses
|
||||||
|
.map((addr) => addr.remoteUrl)
|
||||||
|
.filter((url) => url !== serverMeta.remoteUrl)
|
||||||
|
|
||||||
|
if (additionalRemoteUrls.length > 0) {
|
||||||
|
console.log("Other Accessible URLs:")
|
||||||
|
for (const url of additionalRemoteUrls) {
|
||||||
|
console.log(` - ${url}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (options.launch) {
|
if (options.launch) {
|
||||||
|
|||||||
@@ -0,0 +1,94 @@
|
|||||||
|
import assert from "node:assert/strict"
|
||||||
|
import os from "node:os"
|
||||||
|
import { describe, it } from "node:test"
|
||||||
|
|
||||||
|
import { resolveNetworkAddresses, resolveRemoteAddresses } from "../network-addresses"
|
||||||
|
|
||||||
|
describe("resolveNetworkAddresses", () => {
|
||||||
|
it("preserves interface order among external addresses", () => {
|
||||||
|
const addresses = [
|
||||||
|
{ address: "172.24.0.1", family: "IPv4", internal: false },
|
||||||
|
{ address: "192.168.1.128", family: "IPv4", internal: false },
|
||||||
|
{ address: "10.0.0.8", family: 4, internal: false },
|
||||||
|
{ address: "127.0.0.1", family: "IPv4", internal: true },
|
||||||
|
{ address: "169.254.10.20", family: "IPv4", internal: false },
|
||||||
|
]
|
||||||
|
|
||||||
|
usingMockedNetworkInterfaces(addresses, () => {
|
||||||
|
const result = resolveNetworkAddresses({ host: "0.0.0.0", protocol: "https", port: 9898 })
|
||||||
|
|
||||||
|
assert.deepEqual(
|
||||||
|
result.map((entry) => entry.ip),
|
||||||
|
["172.24.0.1", "192.168.1.128", "10.0.0.8", "169.254.10.20", "127.0.0.1"],
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("resolveRemoteAddresses", () => {
|
||||||
|
it("keeps all external addresses user-visible while preferring non-link-local addresses for the primary URL", () => {
|
||||||
|
const addresses = [
|
||||||
|
{ address: "169.254.10.20", family: "IPv4", internal: false },
|
||||||
|
{ address: "192.168.1.128", family: "IPv4", internal: false },
|
||||||
|
{ address: "172.24.0.1", family: "IPv4", internal: false },
|
||||||
|
]
|
||||||
|
|
||||||
|
usingMockedNetworkInterfaces(addresses, () => {
|
||||||
|
const result = resolveRemoteAddresses({ host: "0.0.0.0", protocol: "https", port: 9898 })
|
||||||
|
|
||||||
|
assert.deepEqual(
|
||||||
|
result.userVisible.map((entry) => entry.ip),
|
||||||
|
["192.168.1.128", "172.24.0.1", "169.254.10.20"],
|
||||||
|
)
|
||||||
|
assert.equal(result.primaryRemoteUrl, "https://192.168.1.128:9898")
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it("prefers private LAN addresses over public addresses", () => {
|
||||||
|
const addresses = [
|
||||||
|
{ address: "203.0.113.40", family: "IPv4", internal: false },
|
||||||
|
{ address: "192.168.1.128", family: "IPv4", internal: false },
|
||||||
|
{ address: "8.8.8.8", family: "IPv4", internal: false },
|
||||||
|
]
|
||||||
|
|
||||||
|
usingMockedNetworkInterfaces(addresses, () => {
|
||||||
|
const result = resolveRemoteAddresses({ host: "0.0.0.0", protocol: "https", port: 9898 })
|
||||||
|
|
||||||
|
assert.deepEqual(
|
||||||
|
result.userVisible.map((entry) => entry.ip),
|
||||||
|
["192.168.1.128", "203.0.113.40", "8.8.8.8"],
|
||||||
|
)
|
||||||
|
assert.equal(result.primaryRemoteUrl, "https://192.168.1.128:9898")
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it("uses a public address when no private LAN address is available", () => {
|
||||||
|
const addresses = [
|
||||||
|
{ address: "169.254.10.20", family: "IPv4", internal: false },
|
||||||
|
{ address: "203.0.113.40", family: "IPv4", internal: false },
|
||||||
|
]
|
||||||
|
|
||||||
|
usingMockedNetworkInterfaces(addresses, () => {
|
||||||
|
const result = resolveRemoteAddresses({ host: "0.0.0.0", protocol: "https", port: 9898 })
|
||||||
|
|
||||||
|
assert.deepEqual(result.userVisible.map((entry) => entry.ip), ["203.0.113.40", "169.254.10.20"])
|
||||||
|
assert.equal(result.primaryRemoteUrl, "https://203.0.113.40:9898")
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
function usingMockedNetworkInterfaces(
|
||||||
|
addresses: Array<{ address: string; family: string | number; internal: boolean }>,
|
||||||
|
callback: () => void,
|
||||||
|
) {
|
||||||
|
const original = os.networkInterfaces
|
||||||
|
os.networkInterfaces = (() => ({
|
||||||
|
ethernet0: addresses as unknown as ReturnType<typeof os.networkInterfaces>[string],
|
||||||
|
})) as typeof os.networkInterfaces
|
||||||
|
|
||||||
|
try {
|
||||||
|
callback()
|
||||||
|
} finally {
|
||||||
|
os.networkInterfaces = original
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,12 @@
|
|||||||
import os from "os"
|
import os from "os"
|
||||||
import type { NetworkAddress } from "../api-types"
|
import type { NetworkAddress } from "../api-types"
|
||||||
|
|
||||||
|
export interface ResolvedRemoteAddresses {
|
||||||
|
all: NetworkAddress[]
|
||||||
|
userVisible: NetworkAddress[]
|
||||||
|
primaryRemoteUrl?: string
|
||||||
|
}
|
||||||
|
|
||||||
export function resolveNetworkAddresses(args: {
|
export function resolveNetworkAddresses(args: {
|
||||||
host: string
|
host: string
|
||||||
protocol: "http" | "https"
|
protocol: "http" | "https"
|
||||||
@@ -58,10 +64,57 @@ export function resolveNetworkAddresses(args: {
|
|||||||
return results.sort((a, b) => {
|
return results.sort((a, b) => {
|
||||||
const scopeDelta = scopeWeight[a.scope] - scopeWeight[b.scope]
|
const scopeDelta = scopeWeight[a.scope] - scopeWeight[b.scope]
|
||||||
if (scopeDelta !== 0) return scopeDelta
|
if (scopeDelta !== 0) return scopeDelta
|
||||||
return a.ip.localeCompare(b.ip)
|
|
||||||
|
return 0
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function resolveRemoteAddresses(args: {
|
||||||
|
host: string
|
||||||
|
protocol: "http" | "https"
|
||||||
|
port: number
|
||||||
|
}): ResolvedRemoteAddresses {
|
||||||
|
const all = resolveNetworkAddresses(args)
|
||||||
|
const userVisible = sortUserVisibleAddresses(all.filter((address) => address.scope === "external"))
|
||||||
|
return {
|
||||||
|
all,
|
||||||
|
userVisible,
|
||||||
|
primaryRemoteUrl: userVisible[0]?.remoteUrl,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function sortUserVisibleAddresses(addresses: NetworkAddress[]): NetworkAddress[] {
|
||||||
|
return [...addresses].sort((left, right) => getUserVisiblePriority(left.ip) - getUserVisiblePriority(right.ip))
|
||||||
|
}
|
||||||
|
|
||||||
|
function getUserVisiblePriority(ip: string): number {
|
||||||
|
if (isPrivateIPv4(ip)) return 0
|
||||||
|
if (isLinkLocalIPv4(ip)) return 2
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
function isLinkLocalIPv4(ip: string): boolean {
|
||||||
|
const octets = parseIPv4(ip)
|
||||||
|
if (!octets) return false
|
||||||
|
const [first, second] = octets
|
||||||
|
return first === 169 && second === 254
|
||||||
|
}
|
||||||
|
|
||||||
|
function isPrivateIPv4(ip: string): boolean {
|
||||||
|
const octets = parseIPv4(ip)
|
||||||
|
if (!octets) return false
|
||||||
|
const [first, second] = octets
|
||||||
|
|
||||||
|
if (first === 10) return true
|
||||||
|
if (first === 192 && second === 168) return true
|
||||||
|
return first === 172 && second >= 16 && second <= 31
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseIPv4(value: string): number[] | null {
|
||||||
|
if (!isIPv4Address(value)) return null
|
||||||
|
return value.split(".").map((part) => Number(part))
|
||||||
|
}
|
||||||
|
|
||||||
function isIPv4Address(value: string | undefined): value is string {
|
function isIPv4Address(value: string | undefined): value is string {
|
||||||
if (!value) return false
|
if (!value) return false
|
||||||
const parts = value.split(".")
|
const parts = value.split(".")
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { FastifyInstance } from "fastify"
|
import { FastifyInstance } from "fastify"
|
||||||
import { ServerMeta } from "../../api-types"
|
import { ServerMeta } from "../../api-types"
|
||||||
import { resolveNetworkAddresses } from "../network-addresses"
|
|
||||||
|
|
||||||
interface RouteDeps {
|
interface RouteDeps {
|
||||||
serverMeta: ServerMeta
|
serverMeta: ServerMeta
|
||||||
@@ -13,14 +13,12 @@ export function registerMetaRoutes(app: FastifyInstance, deps: RouteDeps) {
|
|||||||
function buildMetaResponse(meta: ServerMeta): ServerMeta {
|
function buildMetaResponse(meta: ServerMeta): ServerMeta {
|
||||||
const localPort = resolveLocalPort(meta)
|
const localPort = resolveLocalPort(meta)
|
||||||
const remote = resolveRemote(meta)
|
const remote = resolveRemote(meta)
|
||||||
const addresses = remote && remote.port > 0 ? resolveNetworkAddresses({ host: meta.host, protocol: remote.protocol, port: remote.port }) : []
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...meta,
|
...meta,
|
||||||
localPort,
|
localPort,
|
||||||
remotePort: remote?.port,
|
remotePort: remote?.port,
|
||||||
listeningMode: meta.host === "0.0.0.0" || !isLoopbackHost(meta.host) ? "all" : "local",
|
listeningMode: meta.host === "0.0.0.0" || !isLoopbackHost(meta.host) ? "all" : "local",
|
||||||
addresses,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ use std::process::{Child, Command, Stdio};
|
|||||||
use std::sync::atomic::{AtomicBool, Ordering};
|
use std::sync::atomic::{AtomicBool, Ordering};
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use std::thread;
|
use std::thread;
|
||||||
use std::time::{Duration, Instant};
|
use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
|
||||||
use tauri::{webview::cookie::Cookie, AppHandle, Emitter, Manager, Url};
|
use tauri::{webview::cookie::Cookie, AppHandle, Emitter, Manager, Url};
|
||||||
|
|
||||||
#[cfg(windows)]
|
#[cfg(windows)]
|
||||||
@@ -48,7 +48,7 @@ fn workspace_root() -> Option<PathBuf> {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const SESSION_COOKIE_NAME: &str = "codenomad_session";
|
const SESSION_COOKIE_NAME_PREFIX: &str = "codenomad_session";
|
||||||
|
|
||||||
const CLI_STOP_GRACE_SECS: u64 = 30;
|
const CLI_STOP_GRACE_SECS: u64 = 30;
|
||||||
#[cfg(windows)]
|
#[cfg(windows)]
|
||||||
@@ -124,7 +124,11 @@ fn extract_cookie_value(set_cookie: &str, name: &str) -> Option<String> {
|
|||||||
Some(value.to_string())
|
Some(value.to_string())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn exchange_bootstrap_token(base_url: &str, token: &str) -> anyhow::Result<Option<String>> {
|
fn exchange_bootstrap_token(
|
||||||
|
base_url: &str,
|
||||||
|
token: &str,
|
||||||
|
cookie_name: &str,
|
||||||
|
) -> anyhow::Result<Option<String>> {
|
||||||
let parsed = Url::parse(base_url)?;
|
let parsed = Url::parse(base_url)?;
|
||||||
let host = parsed.host_str().unwrap_or("127.0.0.1");
|
let host = parsed.host_str().unwrap_or("127.0.0.1");
|
||||||
let port = parsed.port_or_known_default().unwrap_or(80);
|
let port = parsed.port_or_known_default().unwrap_or(80);
|
||||||
@@ -159,11 +163,11 @@ fn exchange_bootstrap_token(base_url: &str, token: &str) -> anyhow::Result<Optio
|
|||||||
for line in lines {
|
for line in lines {
|
||||||
// handle case-insensitive header name
|
// handle case-insensitive header name
|
||||||
if let Some(value) = line.strip_prefix("Set-Cookie:") {
|
if let Some(value) = line.strip_prefix("Set-Cookie:") {
|
||||||
if let Some(session_id) = extract_cookie_value(value.trim(), SESSION_COOKIE_NAME) {
|
if let Some(session_id) = extract_cookie_value(value.trim(), cookie_name) {
|
||||||
return Ok(Some(session_id));
|
return Ok(Some(session_id));
|
||||||
}
|
}
|
||||||
} else if let Some(value) = line.strip_prefix("set-cookie:") {
|
} else if let Some(value) = line.strip_prefix("set-cookie:") {
|
||||||
if let Some(session_id) = extract_cookie_value(value.trim(), SESSION_COOKIE_NAME) {
|
if let Some(session_id) = extract_cookie_value(value.trim(), cookie_name) {
|
||||||
return Ok(Some(session_id));
|
return Ok(Some(session_id));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -172,11 +176,16 @@ fn exchange_bootstrap_token(base_url: &str, token: &str) -> anyhow::Result<Optio
|
|||||||
Ok(None)
|
Ok(None)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn set_session_cookie(app: &AppHandle, base_url: &str, session_id: &str) -> anyhow::Result<()> {
|
fn set_session_cookie(
|
||||||
|
app: &AppHandle,
|
||||||
|
base_url: &str,
|
||||||
|
cookie_name: &str,
|
||||||
|
session_id: &str,
|
||||||
|
) -> anyhow::Result<()> {
|
||||||
let parsed = Url::parse(base_url)?;
|
let parsed = Url::parse(base_url)?;
|
||||||
let domain = parsed.host_str().unwrap_or("127.0.0.1").to_string();
|
let domain = parsed.host_str().unwrap_or("127.0.0.1").to_string();
|
||||||
|
|
||||||
let cookie = Cookie::build((SESSION_COOKIE_NAME, session_id))
|
let cookie = Cookie::build((cookie_name.to_string(), session_id.to_string()))
|
||||||
.domain(domain)
|
.domain(domain)
|
||||||
.path("/")
|
.path("/")
|
||||||
.http_only(true)
|
.http_only(true)
|
||||||
@@ -190,6 +199,16 @@ fn set_session_cookie(app: &AppHandle, base_url: &str, session_id: &str) -> anyh
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn generate_auth_cookie_name() -> String {
|
||||||
|
let pid = std::process::id();
|
||||||
|
let timestamp = SystemTime::now()
|
||||||
|
.duration_since(UNIX_EPOCH)
|
||||||
|
.map(|duration| duration.as_millis())
|
||||||
|
.unwrap_or(0);
|
||||||
|
|
||||||
|
format!("{SESSION_COOKIE_NAME_PREFIX}_{pid}_{timestamp}")
|
||||||
|
}
|
||||||
|
|
||||||
const DEFAULT_CONFIG_PATH: &str = "~/.config/codenomad/config.json";
|
const DEFAULT_CONFIG_PATH: &str = "~/.config/codenomad/config.json";
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
@@ -503,7 +522,8 @@ impl CliProcessManager {
|
|||||||
"resolved CLI entry runner={:?} entry={} host={}",
|
"resolved CLI entry runner={:?} entry={} host={}",
|
||||||
resolution.runner, resolution.entry, host
|
resolution.runner, resolution.entry, host
|
||||||
));
|
));
|
||||||
let args = resolution.build_args(dev, &host);
|
let auth_cookie_name = Arc::new(generate_auth_cookie_name());
|
||||||
|
let args = resolution.build_args(dev, &host, auth_cookie_name.as_str());
|
||||||
log_line(&format!("CLI args: {:?}", args));
|
log_line(&format!("CLI args: {:?}", args));
|
||||||
if dev {
|
if dev {
|
||||||
log_line("development mode: will prefer tsx + source if present");
|
log_line("development mode: will prefer tsx + source if present");
|
||||||
@@ -584,6 +604,7 @@ impl CliProcessManager {
|
|||||||
let app_clone = app.clone();
|
let app_clone = app.clone();
|
||||||
let ready_clone = ready.clone();
|
let ready_clone = ready.clone();
|
||||||
let token_clone = bootstrap_token.clone();
|
let token_clone = bootstrap_token.clone();
|
||||||
|
let auth_cookie_name_clone = auth_cookie_name.clone();
|
||||||
|
|
||||||
thread::spawn(move || {
|
thread::spawn(move || {
|
||||||
let stdout = child_clone
|
let stdout = child_clone
|
||||||
@@ -605,6 +626,7 @@ impl CliProcessManager {
|
|||||||
&status_clone,
|
&status_clone,
|
||||||
&ready_clone,
|
&ready_clone,
|
||||||
&token_clone,
|
&token_clone,
|
||||||
|
auth_cookie_name_clone.as_str(),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if let Some(reader) = stderr {
|
if let Some(reader) = stderr {
|
||||||
@@ -615,6 +637,7 @@ impl CliProcessManager {
|
|||||||
&status_clone,
|
&status_clone,
|
||||||
&ready_clone,
|
&ready_clone,
|
||||||
&token_clone,
|
&token_clone,
|
||||||
|
auth_cookie_name_clone.as_str(),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -731,6 +754,7 @@ impl CliProcessManager {
|
|||||||
status: &Arc<Mutex<CliStatus>>,
|
status: &Arc<Mutex<CliStatus>>,
|
||||||
ready: &Arc<AtomicBool>,
|
ready: &Arc<AtomicBool>,
|
||||||
bootstrap_token: &Arc<Mutex<Option<String>>>,
|
bootstrap_token: &Arc<Mutex<Option<String>>>,
|
||||||
|
auth_cookie_name: &str,
|
||||||
) {
|
) {
|
||||||
let mut buffer = String::new();
|
let mut buffer = String::new();
|
||||||
let local_url_regex = Regex::new(r"^Local\s+Connection\s+URL\s*:\s*(https?://\S+)").ok();
|
let local_url_regex = Regex::new(r"^Local\s+Connection\s+URL\s*:\s*(https?://\S+)").ok();
|
||||||
@@ -766,7 +790,14 @@ impl CliProcessManager {
|
|||||||
.and_then(|re| re.captures(line).and_then(|c| c.get(1)))
|
.and_then(|re| re.captures(line).and_then(|c| c.get(1)))
|
||||||
.map(|m| m.as_str().to_string())
|
.map(|m| m.as_str().to_string())
|
||||||
{
|
{
|
||||||
Self::mark_ready(app, status, ready, bootstrap_token, url);
|
Self::mark_ready(
|
||||||
|
app,
|
||||||
|
status,
|
||||||
|
ready,
|
||||||
|
bootstrap_token,
|
||||||
|
auth_cookie_name,
|
||||||
|
url,
|
||||||
|
);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -781,6 +812,7 @@ impl CliProcessManager {
|
|||||||
status,
|
status,
|
||||||
ready,
|
ready,
|
||||||
bootstrap_token,
|
bootstrap_token,
|
||||||
|
auth_cookie_name,
|
||||||
format!("http://localhost:{port}"),
|
format!("http://localhost:{port}"),
|
||||||
);
|
);
|
||||||
continue;
|
continue;
|
||||||
@@ -793,6 +825,7 @@ impl CliProcessManager {
|
|||||||
status,
|
status,
|
||||||
ready,
|
ready,
|
||||||
bootstrap_token,
|
bootstrap_token,
|
||||||
|
auth_cookie_name,
|
||||||
format!("http://localhost:{}", port),
|
format!("http://localhost:{}", port),
|
||||||
);
|
);
|
||||||
continue;
|
continue;
|
||||||
@@ -811,6 +844,7 @@ impl CliProcessManager {
|
|||||||
status: &Arc<Mutex<CliStatus>>,
|
status: &Arc<Mutex<CliStatus>>,
|
||||||
ready: &Arc<AtomicBool>,
|
ready: &Arc<AtomicBool>,
|
||||||
bootstrap_token: &Arc<Mutex<Option<String>>>,
|
bootstrap_token: &Arc<Mutex<Option<String>>>,
|
||||||
|
auth_cookie_name: &str,
|
||||||
base_url: String,
|
base_url: String,
|
||||||
) {
|
) {
|
||||||
ready.store(true, Ordering::SeqCst);
|
ready.store(true, Ordering::SeqCst);
|
||||||
@@ -834,9 +868,11 @@ impl CliProcessManager {
|
|||||||
if scheme.as_deref() != Some("http") {
|
if scheme.as_deref() != Some("http") {
|
||||||
navigate_main(app, &base_url);
|
navigate_main(app, &base_url);
|
||||||
} else {
|
} else {
|
||||||
match exchange_bootstrap_token(&base_url, &token) {
|
match exchange_bootstrap_token(&base_url, &token, &auth_cookie_name) {
|
||||||
Ok(Some(session_id)) => {
|
Ok(Some(session_id)) => {
|
||||||
if let Err(err) = set_session_cookie(app, &base_url, &session_id) {
|
if let Err(err) =
|
||||||
|
set_session_cookie(app, &base_url, &auth_cookie_name, &session_id)
|
||||||
|
{
|
||||||
log_line(&format!("failed to set session cookie: {err}"));
|
log_line(&format!("failed to set session cookie: {err}"));
|
||||||
navigate_main(app, &format!("{base_url}/login"));
|
navigate_main(app, &format!("{base_url}/login"));
|
||||||
} else {
|
} else {
|
||||||
@@ -932,11 +968,13 @@ impl CliEntry {
|
|||||||
))
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
fn build_args(&self, dev: bool, host: &str) -> Vec<String> {
|
fn build_args(&self, dev: bool, host: &str, auth_cookie_name: &str) -> Vec<String> {
|
||||||
let mut args = vec![
|
let mut args = vec![
|
||||||
"serve".to_string(),
|
"serve".to_string(),
|
||||||
"--host".to_string(),
|
"--host".to_string(),
|
||||||
host.to_string(),
|
host.to_string(),
|
||||||
|
"--auth-cookie-name".to_string(),
|
||||||
|
auth_cookie_name.to_string(),
|
||||||
"--generate-token".to_string(),
|
"--generate-token".to_string(),
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { createMemo, Show, createEffect, onCleanup } from "solid-js"
|
import { createMemo, Show, createEffect } from "solid-js"
|
||||||
import { DiffView, DiffModeEnum } from "@git-diff-view/solid"
|
import { DiffView, DiffModeEnum } from "@git-diff-view/solid"
|
||||||
import "@git-diff-view/solid/styles/diff-view-pure.css"
|
import "@git-diff-view/solid/styles/diff-view-pure.css"
|
||||||
import { disableCache } from "@git-diff-view/core"
|
import { disableCache } from "@git-diff-view/core"
|
||||||
@@ -20,6 +20,7 @@ interface ToolCallDiffViewerProps {
|
|||||||
filePath?: string
|
filePath?: string
|
||||||
theme: "light" | "dark"
|
theme: "light" | "dark"
|
||||||
mode: DiffViewMode
|
mode: DiffViewMode
|
||||||
|
wrap?: boolean
|
||||||
onRendered?: () => void
|
onRendered?: () => void
|
||||||
cachedHtml?: string
|
cachedHtml?: string
|
||||||
cacheEntryParams?: CacheEntryParams
|
cacheEntryParams?: CacheEntryParams
|
||||||
@@ -31,11 +32,183 @@ type DiffData = {
|
|||||||
hunks: string[]
|
hunks: string[]
|
||||||
}
|
}
|
||||||
|
|
||||||
type CaptureContext = {
|
function measureTextWidth(container: HTMLElement, text: string, source: HTMLElement) {
|
||||||
theme: ToolCallDiffViewerProps["theme"]
|
const computed = window.getComputedStyle(source)
|
||||||
mode: DiffViewMode
|
const probe = document.createElement("span")
|
||||||
diffText: string
|
probe.textContent = text || ""
|
||||||
cacheEntryParams?: CacheEntryParams
|
probe.style.position = "absolute"
|
||||||
|
probe.style.visibility = "hidden"
|
||||||
|
probe.style.pointerEvents = "none"
|
||||||
|
probe.style.display = "inline-block"
|
||||||
|
probe.style.width = "auto"
|
||||||
|
probe.style.maxWidth = "none"
|
||||||
|
probe.style.whiteSpace = "nowrap"
|
||||||
|
probe.style.fontFamily = computed.fontFamily
|
||||||
|
probe.style.fontSize = computed.fontSize
|
||||||
|
probe.style.fontWeight = computed.fontWeight
|
||||||
|
probe.style.fontStyle = computed.fontStyle
|
||||||
|
probe.style.letterSpacing = computed.letterSpacing
|
||||||
|
probe.style.fontVariant = computed.fontVariant
|
||||||
|
probe.style.textTransform = computed.textTransform
|
||||||
|
probe.style.lineHeight = computed.lineHeight
|
||||||
|
container.appendChild(probe)
|
||||||
|
const width = Math.ceil(probe.getBoundingClientRect().width)
|
||||||
|
probe.remove()
|
||||||
|
return width
|
||||||
|
}
|
||||||
|
|
||||||
|
function computeCompactWidth(
|
||||||
|
container: HTMLElement,
|
||||||
|
entries: Array<{ text: string; source: HTMLElement }>,
|
||||||
|
maxWidthPx = 40,
|
||||||
|
) {
|
||||||
|
const measuredLabelWidthPx = entries.reduce((max, entry) => {
|
||||||
|
return Math.max(max, measureTextWidth(container, entry.text, entry.source))
|
||||||
|
}, 0)
|
||||||
|
const fallbackTextLength = entries.reduce((max, entry) => Math.max(max, entry.text.length), 1)
|
||||||
|
const fallbackWidthPx = Math.round(fallbackTextLength * 7 + 4)
|
||||||
|
return Math.max(2, Math.min(maxWidthPx, measuredLabelWidthPx > 0 ? measuredLabelWidthPx + 2 : fallbackWidthPx))
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyCompactUnifiedGutter(container: HTMLElement, wrap: boolean) {
|
||||||
|
const tableWrapper = container.querySelector<HTMLElement>(".unified-diff-table-wrapper")
|
||||||
|
const table = container.querySelector<HTMLTableElement>(".unified-diff-table")
|
||||||
|
const numberCol = container.querySelector<HTMLTableColElement>(".unified-diff-table-num-col")
|
||||||
|
const gutterRows = container.querySelectorAll<HTMLElement>(".diff-line-num")
|
||||||
|
const hunkGutters = container.querySelectorAll<HTMLElement>(".diff-line-hunk-action, .diff-line-widget-wrapper, .diff-line-extend-wrapper")
|
||||||
|
const entries: Array<{ gutter: HTMLElement; label: HTMLElement; text: string }> = []
|
||||||
|
|
||||||
|
if (table) {
|
||||||
|
if (wrap) {
|
||||||
|
table.classList.add("table-fixed")
|
||||||
|
table.style.tableLayout = "fixed"
|
||||||
|
table.style.width = "100%"
|
||||||
|
table.style.minWidth = "100%"
|
||||||
|
} else {
|
||||||
|
table.classList.remove("table-fixed")
|
||||||
|
table.style.tableLayout = "auto"
|
||||||
|
table.style.width = "max-content"
|
||||||
|
table.style.minWidth = "100%"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
gutterRows.forEach((gutter) => {
|
||||||
|
const oldSpan = gutter.querySelector<HTMLElement>("[data-line-old-num]")
|
||||||
|
const newSpan = gutter.querySelector<HTMLElement>("[data-line-new-num]")
|
||||||
|
const spacer = gutter.querySelector<HTMLElement>(".shrink-0")
|
||||||
|
const flexWrapper = gutter.querySelector<HTMLElement>(":scope > .flex")
|
||||||
|
const currentLabel = gutter.querySelector<HTMLElement>(":scope > .tool-call-diff-compact-line-number")
|
||||||
|
|
||||||
|
const oldText = oldSpan?.textContent?.trim() ?? ""
|
||||||
|
const newText = newSpan?.textContent?.trim() ?? ""
|
||||||
|
const hasUsableNew = newText.length > 0 && newText !== "0"
|
||||||
|
const hasUsableOld = oldText.length > 0 && oldText !== "0"
|
||||||
|
const visibleText = hasUsableNew ? newText : hasUsableOld ? oldText : newText || oldText
|
||||||
|
|
||||||
|
if (flexWrapper) flexWrapper.style.display = "none"
|
||||||
|
if (spacer) spacer.style.display = "none"
|
||||||
|
if (oldSpan) { oldSpan.style.display = "none"; oldSpan.style.width = "auto" }
|
||||||
|
if (newSpan) { newSpan.style.display = "none"; newSpan.style.width = "auto" }
|
||||||
|
|
||||||
|
gutter.style.paddingLeft = "1px"
|
||||||
|
gutter.style.paddingRight = "1px"
|
||||||
|
gutter.style.textAlign = "left"
|
||||||
|
|
||||||
|
const label = currentLabel ?? document.createElement("span")
|
||||||
|
label.className = "tool-call-diff-compact-line-number"
|
||||||
|
label.textContent = visibleText
|
||||||
|
label.setAttribute("aria-hidden", visibleText ? "false" : "true")
|
||||||
|
if (!currentLabel) gutter.appendChild(label)
|
||||||
|
|
||||||
|
entries.push({ gutter, label, text: visibleText })
|
||||||
|
})
|
||||||
|
|
||||||
|
const gutterWidthPx = computeCompactWidth(container, entries.map((entry) => ({ text: entry.text, source: entry.label })))
|
||||||
|
const gutterWidth = `${gutterWidthPx}px`
|
||||||
|
const compactAsideWidth = `${Math.max(8, gutterWidthPx - 10)}px`
|
||||||
|
|
||||||
|
if (tableWrapper) {
|
||||||
|
tableWrapper.style.setProperty("--diff-aside-width", compactAsideWidth)
|
||||||
|
tableWrapper.style.setProperty("--diff-aside-width--", compactAsideWidth)
|
||||||
|
}
|
||||||
|
if (numberCol) {
|
||||||
|
numberCol.style.width = gutterWidth
|
||||||
|
}
|
||||||
|
|
||||||
|
entries.forEach(({ gutter, label }) => {
|
||||||
|
gutter.style.width = gutterWidth
|
||||||
|
gutter.style.minWidth = gutterWidth
|
||||||
|
gutter.style.maxWidth = gutterWidth
|
||||||
|
label.style.width = "auto"
|
||||||
|
label.style.maxWidth = "none"
|
||||||
|
})
|
||||||
|
|
||||||
|
hunkGutters.forEach((gutter) => {
|
||||||
|
gutter.style.width = gutterWidth
|
||||||
|
gutter.style.minWidth = gutterWidth
|
||||||
|
gutter.style.maxWidth = gutterWidth
|
||||||
|
gutter.style.paddingLeft = "0"
|
||||||
|
gutter.style.paddingRight = "0"
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyCompactSplitGutter(container: HTMLElement) {
|
||||||
|
const oldWrapper = container.querySelector<HTMLElement>(".old-diff-table-wrapper")
|
||||||
|
const newWrapper = container.querySelector<HTMLElement>(".new-diff-table-wrapper")
|
||||||
|
const numberCells = Array.from(container.querySelectorAll<HTMLElement>(".diff-line-old-num, .diff-line-new-num"))
|
||||||
|
const hunkActions = Array.from(container.querySelectorAll<HTMLElement>(".diff-line-hunk-action, .diff-line-widget-wrapper, .diff-line-extend-wrapper"))
|
||||||
|
const numberSpans = numberCells
|
||||||
|
.map((cell) => ({ cell, span: cell.querySelector<HTMLElement>("[data-line-num]") }))
|
||||||
|
.filter((entry): entry is { cell: HTMLElement; span: HTMLElement } => Boolean(entry.span))
|
||||||
|
|
||||||
|
const gutterWidthPx = computeCompactWidth(
|
||||||
|
container,
|
||||||
|
numberSpans.map(({ span }) => ({ text: span.textContent?.trim() ?? "", source: span })),
|
||||||
|
64,
|
||||||
|
)
|
||||||
|
const gutterWidth = `${gutterWidthPx}px`
|
||||||
|
|
||||||
|
;[oldWrapper, newWrapper].forEach((wrapper) => {
|
||||||
|
if (wrapper) {
|
||||||
|
wrapper.style.setProperty("--diff-aside-width", gutterWidth)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
numberCells.forEach((cell) => {
|
||||||
|
cell.style.width = gutterWidth
|
||||||
|
cell.style.minWidth = gutterWidth
|
||||||
|
cell.style.maxWidth = gutterWidth
|
||||||
|
cell.style.paddingLeft = "2px"
|
||||||
|
cell.style.paddingRight = "2px"
|
||||||
|
cell.style.textAlign = "left"
|
||||||
|
cell.style.whiteSpace = "nowrap"
|
||||||
|
cell.style.overflowWrap = "normal"
|
||||||
|
cell.style.wordBreak = "normal"
|
||||||
|
})
|
||||||
|
|
||||||
|
numberSpans.forEach(({ span }) => {
|
||||||
|
span.style.whiteSpace = "nowrap"
|
||||||
|
span.style.overflowWrap = "normal"
|
||||||
|
span.style.wordBreak = "normal"
|
||||||
|
})
|
||||||
|
|
||||||
|
hunkActions.forEach((cell) => {
|
||||||
|
cell.style.width = gutterWidth
|
||||||
|
cell.style.minWidth = gutterWidth
|
||||||
|
cell.style.maxWidth = gutterWidth
|
||||||
|
cell.style.paddingLeft = "0"
|
||||||
|
cell.style.paddingRight = "0"
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyCompactDiffLayout(container: HTMLElement, mode: DiffViewMode, wrap = false) {
|
||||||
|
if (mode === "unified") {
|
||||||
|
applyCompactUnifiedGutter(container, wrap)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (mode === "split") {
|
||||||
|
applyCompactSplitGutter(container)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ToolCallDiffViewer(props: ToolCallDiffViewerProps) {
|
export function ToolCallDiffViewer(props: ToolCallDiffViewerProps) {
|
||||||
@@ -67,12 +240,15 @@ export function ToolCallDiffViewer(props: ToolCallDiffViewerProps) {
|
|||||||
const contextKey = createMemo(() => {
|
const contextKey = createMemo(() => {
|
||||||
const data = diffData()
|
const data = diffData()
|
||||||
if (!data) return ""
|
if (!data) return ""
|
||||||
return `${props.theme}|${props.mode}|${props.diffText}`
|
return `${props.theme}|${props.mode}|${props.wrap ? "wrap" : "nowrap"}|${props.diffText}`
|
||||||
})
|
})
|
||||||
|
|
||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
const cachedHtml = props.cachedHtml
|
const cachedHtml = props.cachedHtml
|
||||||
if (cachedHtml) {
|
if (cachedHtml) {
|
||||||
|
if (diffContainerRef) {
|
||||||
|
applyCompactDiffLayout(diffContainerRef, props.mode, Boolean(props.wrap))
|
||||||
|
}
|
||||||
// When we are given cached HTML, we rely on the caller's cache
|
// When we are given cached HTML, we rely on the caller's cache
|
||||||
// and simply notify once rendered.
|
// and simply notify once rendered.
|
||||||
props.onRendered?.()
|
props.onRendered?.()
|
||||||
@@ -83,9 +259,10 @@ export function ToolCallDiffViewer(props: ToolCallDiffViewerProps) {
|
|||||||
if (!key) return
|
if (!key) return
|
||||||
if (!diffContainerRef) return
|
if (!diffContainerRef) return
|
||||||
if (lastCapturedKey === key) return
|
if (lastCapturedKey === key) return
|
||||||
|
|
||||||
requestAnimationFrame(() => {
|
requestAnimationFrame(() => {
|
||||||
if (!diffContainerRef) return
|
if (!diffContainerRef) return
|
||||||
|
applyCompactDiffLayout(diffContainerRef, props.mode, Boolean(props.wrap))
|
||||||
const markup = diffContainerRef.innerHTML
|
const markup = diffContainerRef.innerHTML
|
||||||
if (!markup) return
|
if (!markup) return
|
||||||
lastCapturedKey = key
|
lastCapturedKey = key
|
||||||
@@ -95,6 +272,7 @@ export function ToolCallDiffViewer(props: ToolCallDiffViewerProps) {
|
|||||||
html: markup,
|
html: markup,
|
||||||
theme: props.theme,
|
theme: props.theme,
|
||||||
mode: props.mode,
|
mode: props.mode,
|
||||||
|
wrap: props.wrap,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
props.onRendered?.()
|
props.onRendered?.()
|
||||||
@@ -122,7 +300,7 @@ export function ToolCallDiffViewer(props: ToolCallDiffViewerProps) {
|
|||||||
diffViewMode={props.mode === "split" ? DiffModeEnum.Split : DiffModeEnum.Unified}
|
diffViewMode={props.mode === "split" ? DiffModeEnum.Split : DiffModeEnum.Unified}
|
||||||
diffViewTheme={props.theme}
|
diffViewTheme={props.theme}
|
||||||
diffViewHighlight
|
diffViewHighlight
|
||||||
diffViewWrap={false}
|
diffViewWrap={Boolean(props.wrap)}
|
||||||
diffViewFontSize={13}
|
diffViewFontSize={13}
|
||||||
/>
|
/>
|
||||||
</ErrorBoundary>
|
</ErrorBoundary>
|
||||||
@@ -131,7 +309,7 @@ export function ToolCallDiffViewer(props: ToolCallDiffViewerProps) {
|
|||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<div innerHTML={props.cachedHtml} />
|
<div ref={diffContainerRef} innerHTML={props.cachedHtml} />
|
||||||
</Show>
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { Dialog } from "@kobalte/core/dialog"
|
|||||||
import { Switch } from "@kobalte/core/switch"
|
import { Switch } from "@kobalte/core/switch"
|
||||||
import { For, Show, createEffect, createMemo, createSignal } from "solid-js"
|
import { For, Show, createEffect, createMemo, createSignal } from "solid-js"
|
||||||
import { toDataURL } from "qrcode"
|
import { toDataURL } from "qrcode"
|
||||||
import { ExternalLink, Link2, Loader2, RefreshCw, Shield, Wifi } from "lucide-solid"
|
import { ChevronDown, ExternalLink, Link2, Loader2, RefreshCw, Shield, Wifi } from "lucide-solid"
|
||||||
import type { NetworkAddress, ServerMeta } from "../../../server/src/api-types"
|
import type { NetworkAddress, ServerMeta } from "../../../server/src/api-types"
|
||||||
import { serverApi } from "../lib/api-client"
|
import { serverApi } from "../lib/api-client"
|
||||||
import { restartCli } from "../lib/native/cli"
|
import { restartCli } from "../lib/native/cli"
|
||||||
@@ -10,6 +10,7 @@ import { serverSettings, setListeningMode } from "../stores/preferences"
|
|||||||
import { showConfirmDialog } from "../stores/alerts"
|
import { showConfirmDialog } from "../stores/alerts"
|
||||||
import { getLogger } from "../lib/logger"
|
import { getLogger } from "../lib/logger"
|
||||||
import { useI18n } from "../lib/i18n"
|
import { useI18n } from "../lib/i18n"
|
||||||
|
import { splitRemoteAddresses, type RemoteAddressGroups } from "../lib/remote-access-addresses"
|
||||||
const log = getLogger("actions")
|
const log = getLogger("actions")
|
||||||
|
|
||||||
|
|
||||||
@@ -32,17 +33,17 @@ export function RemoteAccessOverlay(props: RemoteAccessOverlayProps) {
|
|||||||
const [passwordConfirm, setPasswordConfirm] = createSignal("")
|
const [passwordConfirm, setPasswordConfirm] = createSignal("")
|
||||||
const [passwordError, setPasswordError] = createSignal<string | null>(null)
|
const [passwordError, setPasswordError] = createSignal<string | null>(null)
|
||||||
const [savingPassword, setSavingPassword] = createSignal(false)
|
const [savingPassword, setSavingPassword] = createSignal(false)
|
||||||
|
const [showAllAddresses, setShowAllAddresses] = createSignal(false)
|
||||||
|
|
||||||
const addresses = createMemo<NetworkAddress[]>(() => meta()?.addresses ?? [])
|
const addresses = createMemo<NetworkAddress[]>(() => meta()?.addresses ?? [])
|
||||||
const currentMode = createMemo(() => meta()?.listeningMode ?? serverSettings().listeningMode)
|
const currentMode = createMemo(() => meta()?.listeningMode ?? serverSettings().listeningMode)
|
||||||
const allowExternalConnections = createMemo(() => currentMode() === "all")
|
const allowExternalConnections = createMemo(() => currentMode() === "all")
|
||||||
const displayAddresses = createMemo(() => {
|
const displayAddresses = createMemo<RemoteAddressGroups>(() => {
|
||||||
const list = addresses()
|
const list = addresses()
|
||||||
if (!allowExternalConnections()) {
|
if (!allowExternalConnections()) {
|
||||||
return []
|
return { recommended: null, hidden: [] }
|
||||||
}
|
}
|
||||||
// Local URL is displayed separately; list only remote-friendly addresses.
|
return splitRemoteAddresses(list)
|
||||||
return list.filter((address) => address.scope !== "loopback")
|
|
||||||
})
|
})
|
||||||
|
|
||||||
const refreshMeta = async () => {
|
const refreshMeta = async () => {
|
||||||
@@ -53,6 +54,7 @@ export function RemoteAccessOverlay(props: RemoteAccessOverlayProps) {
|
|||||||
const [metaResult, authResult] = await Promise.all([serverApi.fetchServerMeta(), serverApi.fetchAuthStatus()])
|
const [metaResult, authResult] = await Promise.all([serverApi.fetchServerMeta(), serverApi.fetchAuthStatus()])
|
||||||
setMeta(metaResult)
|
setMeta(metaResult)
|
||||||
setAuthStatus(authResult)
|
setAuthStatus(authResult)
|
||||||
|
setShowAllAddresses(false)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err instanceof Error ? err.message : String(err))
|
setError(err instanceof Error ? err.message : String(err))
|
||||||
} finally {
|
} finally {
|
||||||
@@ -326,7 +328,7 @@ export function RemoteAccessOverlay(props: RemoteAccessOverlayProps) {
|
|||||||
|
|
||||||
<Show when={!loading()} fallback={<div class="remote-card">{t("remoteAccess.addresses.loading")}</div>}>
|
<Show when={!loading()} fallback={<div class="remote-card">{t("remoteAccess.addresses.loading")}</div>}>
|
||||||
<Show when={!error()} fallback={<div class="remote-error">{error()}</div>}>
|
<Show when={!error()} fallback={<div class="remote-error">{error()}</div>}>
|
||||||
<Show when={displayAddresses().length > 0} fallback={<div class="remote-card">{t("remoteAccess.addresses.none")}</div>}>
|
<Show when={displayAddresses().recommended || meta()?.localUrl} fallback={<div class="remote-card">{t("remoteAccess.addresses.none")}</div>}>
|
||||||
<div class="remote-address-list">
|
<div class="remote-address-list">
|
||||||
<Show when={meta()?.localUrl}>
|
<Show when={meta()?.localUrl}>
|
||||||
{(url) => {
|
{(url) => {
|
||||||
@@ -373,8 +375,9 @@ export function RemoteAccessOverlay(props: RemoteAccessOverlayProps) {
|
|||||||
)
|
)
|
||||||
}}
|
}}
|
||||||
</Show>
|
</Show>
|
||||||
<For each={displayAddresses()}>
|
<Show when={displayAddresses().recommended}>
|
||||||
{(address) => {
|
{(addressAccessor) => {
|
||||||
|
const address = addressAccessor()
|
||||||
const url = address.remoteUrl
|
const url = address.remoteUrl
|
||||||
const expandedState = () => expandedUrl() === url
|
const expandedState = () => expandedUrl() === url
|
||||||
const qr = () => qrCodes()[url]
|
const qr = () => qrCodes()[url]
|
||||||
@@ -384,13 +387,14 @@ export function RemoteAccessOverlay(props: RemoteAccessOverlayProps) {
|
|||||||
: address.scope === "loopback"
|
: address.scope === "loopback"
|
||||||
? t("remoteAccess.address.scope.loopback")
|
? t("remoteAccess.address.scope.loopback")
|
||||||
: t("remoteAccess.address.scope.internal")
|
: t("remoteAccess.address.scope.internal")
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div class="remote-address">
|
<div class="remote-address">
|
||||||
<div class="remote-address-main">
|
<div class="remote-address-main">
|
||||||
<div>
|
<div>
|
||||||
<p class="remote-address-url">{url}</p>
|
<p class="remote-address-url">{url}</p>
|
||||||
<p class="remote-address-meta">
|
<p class="remote-address-meta">
|
||||||
{address.family.toUpperCase()} • {scopeLabel()} • {address.ip}
|
{address.family.toUpperCase()} - {scopeLabel()} - {address.ip}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="remote-actions">
|
<div class="remote-actions">
|
||||||
@@ -425,7 +429,83 @@ export function RemoteAccessOverlay(props: RemoteAccessOverlayProps) {
|
|||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}}
|
}}
|
||||||
</For>
|
</Show>
|
||||||
|
|
||||||
|
<Show when={displayAddresses().hidden.length > 0}>
|
||||||
|
<div class="remote-address-disclosure" data-expanded={showAllAddresses()}>
|
||||||
|
<button
|
||||||
|
class="remote-address-disclosure-trigger"
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShowAllAddresses(!showAllAddresses())}
|
||||||
|
aria-expanded={showAllAddresses()}
|
||||||
|
>
|
||||||
|
<span class="remote-address-disclosure-label">
|
||||||
|
{showAllAddresses()
|
||||||
|
? t("remoteAccess.addresses.actions.hideOther")
|
||||||
|
: t("remoteAccess.addresses.actions.showOther", { count: String(displayAddresses().hidden.length) })}
|
||||||
|
</span>
|
||||||
|
<ChevronDown class={`remote-address-disclosure-chevron ${showAllAddresses() ? "is-expanded" : ""}`} />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<Show when={showAllAddresses()}>
|
||||||
|
<div class="remote-address-disclosure-content">
|
||||||
|
<For each={displayAddresses().hidden}>
|
||||||
|
{(address) => {
|
||||||
|
const url = address.remoteUrl
|
||||||
|
const expandedState = () => expandedUrl() === url
|
||||||
|
const qr = () => qrCodes()[url]
|
||||||
|
const scopeLabel = () =>
|
||||||
|
address.scope === "external"
|
||||||
|
? t("remoteAccess.address.scope.network")
|
||||||
|
: address.scope === "loopback"
|
||||||
|
? t("remoteAccess.address.scope.loopback")
|
||||||
|
: t("remoteAccess.address.scope.internal")
|
||||||
|
return (
|
||||||
|
<div class="remote-address">
|
||||||
|
<div class="remote-address-main">
|
||||||
|
<div>
|
||||||
|
<p class="remote-address-url">{url}</p>
|
||||||
|
<p class="remote-address-meta">
|
||||||
|
{address.family.toUpperCase()} • {scopeLabel()} • {address.ip}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="remote-actions">
|
||||||
|
<button class="remote-pill" type="button" onClick={() => handleOpenUrl(url)}>
|
||||||
|
<ExternalLink class="remote-icon" />
|
||||||
|
{t("remoteAccess.address.open")}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="remote-pill"
|
||||||
|
type="button"
|
||||||
|
onClick={() => void toggleExpanded(url)}
|
||||||
|
aria-expanded={expandedState()}
|
||||||
|
>
|
||||||
|
<Link2 class="remote-icon" />
|
||||||
|
{expandedState() ? t("remoteAccess.address.hideQr") : t("remoteAccess.address.showQr")}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Show when={expandedState()}>
|
||||||
|
<div class="remote-qr">
|
||||||
|
<Show when={qr()} fallback={<Loader2 class="remote-icon remote-spin" aria-hidden="true" />}>
|
||||||
|
{(dataUrl) => (
|
||||||
|
<img
|
||||||
|
src={dataUrl()}
|
||||||
|
alt={t("remoteAccess.address.qrAlt", { url })}
|
||||||
|
class="remote-qr-img"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
</For>
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
</Show>
|
</Show>
|
||||||
</Show>
|
</Show>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { Switch } from "@kobalte/core/switch"
|
import { Switch } from "@kobalte/core/switch"
|
||||||
import { For, Show, createMemo, createSignal, type Component, onMount } from "solid-js"
|
import { For, Show, createMemo, createSignal, type Component, onMount } from "solid-js"
|
||||||
import { toDataURL } from "qrcode"
|
import { toDataURL } from "qrcode"
|
||||||
import { ExternalLink, Link2, Loader2, RefreshCw, Shield, Wifi } from "lucide-solid"
|
import { ChevronDown, ExternalLink, Link2, Loader2, RefreshCw, Shield, Wifi } from "lucide-solid"
|
||||||
import type { NetworkAddress, ServerMeta } from "../../../../server/src/api-types"
|
import type { NetworkAddress, ServerMeta } from "../../../../server/src/api-types"
|
||||||
import { serverApi } from "../../lib/api-client"
|
import { serverApi } from "../../lib/api-client"
|
||||||
import { restartCli } from "../../lib/native/cli"
|
import { restartCli } from "../../lib/native/cli"
|
||||||
@@ -9,6 +9,7 @@ import { serverSettings, setListeningMode } from "../../stores/preferences"
|
|||||||
import { showConfirmDialog } from "../../stores/alerts"
|
import { showConfirmDialog } from "../../stores/alerts"
|
||||||
import { getLogger } from "../../lib/logger"
|
import { getLogger } from "../../lib/logger"
|
||||||
import { useI18n } from "../../lib/i18n"
|
import { useI18n } from "../../lib/i18n"
|
||||||
|
import { splitRemoteAddresses, type RemoteAddressGroups } from "../../lib/remote-access-addresses"
|
||||||
|
|
||||||
const log = getLogger("actions")
|
const log = getLogger("actions")
|
||||||
|
|
||||||
@@ -30,14 +31,15 @@ export const RemoteAccessSettingsSection: Component = () => {
|
|||||||
const [passwordConfirm, setPasswordConfirm] = createSignal("")
|
const [passwordConfirm, setPasswordConfirm] = createSignal("")
|
||||||
const [passwordError, setPasswordError] = createSignal<string | null>(null)
|
const [passwordError, setPasswordError] = createSignal<string | null>(null)
|
||||||
const [savingPassword, setSavingPassword] = createSignal(false)
|
const [savingPassword, setSavingPassword] = createSignal(false)
|
||||||
|
const [showAllAddresses, setShowAllAddresses] = createSignal(false)
|
||||||
|
|
||||||
const addresses = createMemo<NetworkAddress[]>(() => meta()?.addresses ?? [])
|
const addresses = createMemo<NetworkAddress[]>(() => meta()?.addresses ?? [])
|
||||||
const currentMode = createMemo(() => meta()?.listeningMode ?? serverSettings().listeningMode)
|
const currentMode = createMemo(() => meta()?.listeningMode ?? serverSettings().listeningMode)
|
||||||
const allowExternalConnections = createMemo(() => currentMode() === "all")
|
const allowExternalConnections = createMemo(() => currentMode() === "all")
|
||||||
const displayAddresses = createMemo(() => {
|
const displayAddresses = createMemo<RemoteAddressGroups>(() => {
|
||||||
const list = addresses()
|
const list = addresses()
|
||||||
if (!allowExternalConnections()) return []
|
if (!allowExternalConnections()) return { recommended: null, hidden: [] }
|
||||||
return list.filter((address) => address.scope !== "loopback")
|
return splitRemoteAddresses(list)
|
||||||
})
|
})
|
||||||
|
|
||||||
const refreshMeta = async () => {
|
const refreshMeta = async () => {
|
||||||
@@ -48,6 +50,7 @@ export const RemoteAccessSettingsSection: Component = () => {
|
|||||||
const [metaResult, authResult] = await Promise.all([serverApi.fetchServerMeta(), serverApi.fetchAuthStatus()])
|
const [metaResult, authResult] = await Promise.all([serverApi.fetchServerMeta(), serverApi.fetchAuthStatus()])
|
||||||
setMeta(metaResult)
|
setMeta(metaResult)
|
||||||
setAuthStatus(authResult)
|
setAuthStatus(authResult)
|
||||||
|
setShowAllAddresses(false)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err instanceof Error ? err.message : String(err))
|
setError(err instanceof Error ? err.message : String(err))
|
||||||
} finally {
|
} finally {
|
||||||
@@ -218,31 +221,35 @@ export const RemoteAccessSettingsSection: Component = () => {
|
|||||||
fallback={<div class="settings-card-message">{t("remoteAccess.authStatus.unavailable")}</div>}
|
fallback={<div class="settings-card-message">{t("remoteAccess.authStatus.unavailable")}</div>}
|
||||||
>
|
>
|
||||||
<div class="settings-card-content">
|
<div class="settings-card-content">
|
||||||
<p class="settings-help-text">{t("remoteAccess.username", { username: authStatus()!.username ?? "codenomad" })}</p>
|
<div class="settings-password-summary-row">
|
||||||
<p class="settings-help-text">
|
<div class="settings-password-summary-copy">
|
||||||
{authStatus()!.passwordUserProvided
|
<p class="settings-help-text">{t("remoteAccess.username", { username: authStatus()!.username ?? "codenomad" })}</p>
|
||||||
? t("remoteAccess.password.status.set")
|
<p class="settings-help-text">
|
||||||
: t("remoteAccess.password.status.unset")}
|
{authStatus()!.passwordUserProvided
|
||||||
</p>
|
? t("remoteAccess.password.status.set")
|
||||||
|
: t("remoteAccess.password.status.unset")}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="settings-password-actions">
|
<div class="settings-password-actions">
|
||||||
<button
|
<button
|
||||||
class="settings-pill-button"
|
class="settings-pill-button"
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setPasswordFormOpen(!passwordFormOpen())
|
setPasswordFormOpen(!passwordFormOpen())
|
||||||
setPasswordError(null)
|
setPasswordError(null)
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{passwordFormOpen()
|
{passwordFormOpen()
|
||||||
? t("remoteAccess.password.actions.cancel")
|
? t("remoteAccess.password.actions.cancel")
|
||||||
: authStatus()!.passwordUserProvided
|
: authStatus()!.passwordUserProvided
|
||||||
? t("remoteAccess.password.actions.change")
|
? t("remoteAccess.password.actions.change")
|
||||||
: t("remoteAccess.password.actions.set")}
|
: t("remoteAccess.password.actions.set")}
|
||||||
</button>
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Show when={passwordFormOpen()}>
|
<Show when={passwordFormOpen()}>
|
||||||
<div class="settings-form-group">
|
<div class="settings-form-group">
|
||||||
<label class="settings-form-label">{t("remoteAccess.password.form.newPassword")}</label>
|
<label class="settings-form-label">{t("remoteAccess.password.form.newPassword")}</label>
|
||||||
<input
|
<input
|
||||||
@@ -292,7 +299,7 @@ export const RemoteAccessSettingsSection: Component = () => {
|
|||||||
<Show when={!loading()} fallback={<div class="remote-card">{t("remoteAccess.addresses.loading")}</div>}>
|
<Show when={!loading()} fallback={<div class="remote-card">{t("remoteAccess.addresses.loading")}</div>}>
|
||||||
<Show when={!error()} fallback={<div class="remote-error">{error()}</div>}>
|
<Show when={!error()} fallback={<div class="remote-error">{error()}</div>}>
|
||||||
<Show
|
<Show
|
||||||
when={displayAddresses().length > 0 || meta()?.localUrl}
|
when={Boolean(displayAddresses().recommended) || meta()?.localUrl}
|
||||||
fallback={<div class="remote-card">{t("remoteAccess.addresses.none")}</div>}
|
fallback={<div class="remote-card">{t("remoteAccess.addresses.none")}</div>}
|
||||||
>
|
>
|
||||||
<div class="remote-address-list">
|
<div class="remote-address-list">
|
||||||
@@ -342,8 +349,9 @@ export const RemoteAccessSettingsSection: Component = () => {
|
|||||||
}}
|
}}
|
||||||
</Show>
|
</Show>
|
||||||
|
|
||||||
<For each={displayAddresses()}>
|
<Show when={displayAddresses().recommended}>
|
||||||
{(address) => {
|
{(addressAccessor) => {
|
||||||
|
const address = addressAccessor()
|
||||||
const url = address.remoteUrl
|
const url = address.remoteUrl
|
||||||
const expandedState = () => expandedUrl() === url
|
const expandedState = () => expandedUrl() === url
|
||||||
const qr = () => qrCodes()[url]
|
const qr = () => qrCodes()[url]
|
||||||
@@ -383,7 +391,11 @@ export const RemoteAccessSettingsSection: Component = () => {
|
|||||||
<div class="remote-qr">
|
<div class="remote-qr">
|
||||||
<Show when={qr()} fallback={<Loader2 class="remote-icon remote-spin" aria-hidden="true" />}>
|
<Show when={qr()} fallback={<Loader2 class="remote-icon remote-spin" aria-hidden="true" />}>
|
||||||
{(dataUrl) => (
|
{(dataUrl) => (
|
||||||
<img src={dataUrl()} alt={t("remoteAccess.address.qrAlt", { url })} class="remote-qr-img" />
|
<img
|
||||||
|
src={dataUrl()}
|
||||||
|
alt={t("remoteAccess.address.qrAlt", { url })}
|
||||||
|
class="remote-qr-img"
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
</Show>
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
@@ -391,7 +403,80 @@ export const RemoteAccessSettingsSection: Component = () => {
|
|||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}}
|
}}
|
||||||
</For>
|
</Show>
|
||||||
|
|
||||||
|
<Show when={displayAddresses().hidden.length > 0}>
|
||||||
|
<div class="remote-address-disclosure" data-expanded={showAllAddresses()}>
|
||||||
|
<button
|
||||||
|
class="remote-address-disclosure-trigger"
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShowAllAddresses(!showAllAddresses())}
|
||||||
|
aria-expanded={showAllAddresses()}
|
||||||
|
>
|
||||||
|
<span class="remote-address-disclosure-label">
|
||||||
|
{showAllAddresses()
|
||||||
|
? t("remoteAccess.addresses.actions.hideOther")
|
||||||
|
: t("remoteAccess.addresses.actions.showOther", { count: String(displayAddresses().hidden.length) })}
|
||||||
|
</span>
|
||||||
|
<ChevronDown class={`remote-address-disclosure-chevron ${showAllAddresses() ? "is-expanded" : ""}`} />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<Show when={showAllAddresses()}>
|
||||||
|
<div class="remote-address-disclosure-content">
|
||||||
|
<For each={displayAddresses().hidden}>
|
||||||
|
{(address) => {
|
||||||
|
const url = address.remoteUrl
|
||||||
|
const expandedState = () => expandedUrl() === url
|
||||||
|
const qr = () => qrCodes()[url]
|
||||||
|
const scopeLabel = () =>
|
||||||
|
address.scope === "external"
|
||||||
|
? t("remoteAccess.address.scope.network")
|
||||||
|
: address.scope === "loopback"
|
||||||
|
? t("remoteAccess.address.scope.loopback")
|
||||||
|
: t("remoteAccess.address.scope.internal")
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div class="remote-address">
|
||||||
|
<div class="remote-address-main">
|
||||||
|
<div>
|
||||||
|
<p class="remote-address-url">{url}</p>
|
||||||
|
<p class="remote-address-meta">
|
||||||
|
{address.family.toUpperCase()} - {scopeLabel()} - {address.ip}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="remote-actions">
|
||||||
|
<button class="remote-pill" type="button" onClick={() => handleOpenUrl(url)}>
|
||||||
|
<ExternalLink class="remote-icon" />
|
||||||
|
{t("remoteAccess.address.open")}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="remote-pill"
|
||||||
|
type="button"
|
||||||
|
onClick={() => void toggleExpanded(url)}
|
||||||
|
aria-expanded={expandedState()}
|
||||||
|
>
|
||||||
|
<Link2 class="remote-icon" />
|
||||||
|
{expandedState() ? t("remoteAccess.address.hideQr") : t("remoteAccess.address.showQr")}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Show when={expandedState()}>
|
||||||
|
<div class="remote-qr">
|
||||||
|
<Show when={qr()} fallback={<Loader2 class="remote-icon remote-spin" aria-hidden="true" />}>
|
||||||
|
{(dataUrl) => (
|
||||||
|
<img src={dataUrl()} alt={t("remoteAccess.address.qrAlt", { url })} class="remote-qr-img" />
|
||||||
|
)}
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
</For>
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
</Show>
|
</Show>
|
||||||
</Show>
|
</Show>
|
||||||
|
|||||||
@@ -1,10 +1,13 @@
|
|||||||
import { Suspense, lazy, onMount, type Accessor, type JSXElement } from "solid-js"
|
import { Suspense, createEffect, createMemo, createSignal, lazy, onMount, type Accessor, type JSXElement } from "solid-js"
|
||||||
import type { ToolState } from "@opencode-ai/sdk/v2"
|
import type { ToolState } from "@opencode-ai/sdk/v2"
|
||||||
|
import useMediaQuery from "@suid/material/useMediaQuery"
|
||||||
|
import { AlignJustify, Copy, Split, WrapText } from "lucide-solid"
|
||||||
import type { RenderCache } from "../../types/message"
|
import type { RenderCache } from "../../types/message"
|
||||||
import type { DiffViewMode } from "../../stores/preferences"
|
import type { DiffViewMode } from "../../stores/preferences"
|
||||||
import type { DiffPayload, DiffRenderOptions, ToolScrollHelpers } from "./types"
|
import type { DiffPayload, DiffRenderOptions, ToolScrollHelpers } from "./types"
|
||||||
import { getRelativePath } from "./utils"
|
import { getRelativePath } from "./utils"
|
||||||
import { getCacheEntry } from "../../lib/global-cache"
|
import { getCacheEntry } from "../../lib/global-cache"
|
||||||
|
import { copyToClipboard } from "../../lib/clipboard"
|
||||||
|
|
||||||
const LazyToolCallDiffViewer = lazy(() =>
|
const LazyToolCallDiffViewer = lazy(() =>
|
||||||
import("../diff-viewer").then((module) => ({ default: module.ToolCallDiffViewer })),
|
import("../diff-viewer").then((module) => ({ default: module.ToolCallDiffViewer })),
|
||||||
@@ -43,6 +46,16 @@ export function createDiffContentRenderer(params: {
|
|||||||
handleScrollRendered: () => void
|
handleScrollRendered: () => void
|
||||||
onContentRendered?: () => void
|
onContentRendered?: () => void
|
||||||
}) {
|
}) {
|
||||||
|
const compactDiffQuery = useMediaQuery("(max-width: 640px)")
|
||||||
|
const [mobileModeOverride, setMobileModeOverride] = createSignal<DiffViewMode | undefined>(undefined)
|
||||||
|
const [wordWrapEnabled, setWordWrapEnabled] = createSignal(true)
|
||||||
|
|
||||||
|
createEffect(() => {
|
||||||
|
if (!compactDiffQuery()) {
|
||||||
|
setMobileModeOverride(undefined)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
const registerTracked = (element: HTMLDivElement | null) => {
|
const registerTracked = (element: HTMLDivElement | null) => {
|
||||||
params.scrollHelpers.registerContainer(element)
|
params.scrollHelpers.registerContainer(element)
|
||||||
}
|
}
|
||||||
@@ -58,7 +71,12 @@ export function createDiffContentRenderer(params: {
|
|||||||
: params.t("toolCall.diff.label"))
|
: params.t("toolCall.diff.label"))
|
||||||
const selectedVariant = options?.variant === "permission-diff" ? "permission-diff" : "diff"
|
const selectedVariant = options?.variant === "permission-diff" ? "permission-diff" : "diff"
|
||||||
const cacheHandle = selectedVariant === "permission-diff" ? params.permissionDiffCache : params.diffCache
|
const cacheHandle = selectedVariant === "permission-diff" ? params.permissionDiffCache : params.diffCache
|
||||||
const diffMode = () => (params.preferences().diffViewMode || "split") as DiffViewMode
|
const preferredMode = () => (params.preferences().diffViewMode || "split") as DiffViewMode
|
||||||
|
const effectiveMode = () => {
|
||||||
|
if (!compactDiffQuery()) return preferredMode()
|
||||||
|
return mobileModeOverride() || "unified"
|
||||||
|
}
|
||||||
|
const shouldWrap = () => wordWrapEnabled()
|
||||||
const themeKey = params.isDark() ? "dark" : "light"
|
const themeKey = params.isDark() ? "dark" : "light"
|
||||||
const state = params.toolState()
|
const state = params.toolState()
|
||||||
const disableScrollTracking = Boolean(
|
const disableScrollTracking = Boolean(
|
||||||
@@ -76,17 +94,40 @@ export function createDiffContentRenderer(params: {
|
|||||||
}
|
}
|
||||||
})()
|
})()
|
||||||
|
|
||||||
let cachedHtml: string | undefined
|
const currentMode = createMemo(() => effectiveMode())
|
||||||
const cached = getCacheEntry<RenderCache>(cacheEntryParams)
|
const currentWrap = createMemo(() => shouldWrap())
|
||||||
const currentMode = diffMode()
|
const cachedHtml = createMemo(() => {
|
||||||
if (cached && cached.text === payload.diffText && cached.theme === themeKey && cached.mode === currentMode) {
|
const cached = getCacheEntry<RenderCache>(cacheEntryParams)
|
||||||
cachedHtml = cached.html
|
if (
|
||||||
}
|
cached
|
||||||
|
&& cached.text === payload.diffText
|
||||||
|
&& cached.theme === themeKey
|
||||||
|
&& cached.mode === currentMode()
|
||||||
|
&& cached.wrap === currentWrap()
|
||||||
|
) {
|
||||||
|
return cached.html
|
||||||
|
}
|
||||||
|
return undefined
|
||||||
|
})
|
||||||
|
|
||||||
const handleModeChange = (mode: DiffViewMode) => {
|
const handleModeChange = (mode: DiffViewMode) => {
|
||||||
|
if (compactDiffQuery()) {
|
||||||
|
setMobileModeOverride(mode)
|
||||||
|
}
|
||||||
params.setDiffViewMode(mode)
|
params.setDiffViewMode(mode)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const nextViewMode = (): DiffViewMode => (currentMode() === "split" ? "unified" : "split")
|
||||||
|
const viewModeTitle = () =>
|
||||||
|
nextViewMode() === "split"
|
||||||
|
? params.t("toolCall.diff.switchToSplit")
|
||||||
|
: params.t("toolCall.diff.switchToUnified")
|
||||||
|
const wordWrapTitle = () =>
|
||||||
|
wordWrapEnabled()
|
||||||
|
? params.t("toolCall.diff.disableWordWrap")
|
||||||
|
: params.t("toolCall.diff.enableWordWrap")
|
||||||
|
const copyPatchTitle = () => params.t("toolCall.diff.copyPatch")
|
||||||
|
|
||||||
const handleDiffRendered = () => {
|
const handleDiffRendered = () => {
|
||||||
if (!disableScrollTracking) {
|
if (!disableScrollTracking) {
|
||||||
params.handleScrollRendered()
|
params.handleScrollRendered()
|
||||||
@@ -95,41 +136,54 @@ export function createDiffContentRenderer(params: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
class="message-text tool-call-markdown tool-call-markdown-large tool-call-diff-shell"
|
class="message-text tool-call-markdown tool-call-markdown-large tool-call-diff-shell"
|
||||||
ref={registerRef}
|
data-diff-mode={currentMode()}
|
||||||
onScroll={disableScrollTracking ? undefined : params.scrollHelpers.handleScroll}
|
ref={registerRef}
|
||||||
>
|
onScroll={disableScrollTracking ? undefined : params.scrollHelpers.handleScroll}
|
||||||
|
>
|
||||||
<div class="tool-call-diff-toolbar" role="group" aria-label={params.t("toolCall.diff.viewMode.ariaLabel")}>
|
<div class="tool-call-diff-toolbar" role="group" aria-label={params.t("toolCall.diff.viewMode.ariaLabel")}>
|
||||||
<span class="tool-call-diff-toolbar-label">{toolbarLabel}</span>
|
<span class="tool-call-diff-toolbar-label">{toolbarLabel}</span>
|
||||||
<div class="tool-call-diff-toggle">
|
<div class="file-viewer-toolbar">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class={`tool-call-diff-mode-button${diffMode() === "split" ? " active" : ""}`}
|
class="file-viewer-toolbar-icon-button"
|
||||||
aria-pressed={diffMode() === "split"}
|
onClick={() => void copyToClipboard(payload.diffText)}
|
||||||
onClick={() => handleModeChange("split")}
|
aria-label={copyPatchTitle()}
|
||||||
|
title={copyPatchTitle()}
|
||||||
>
|
>
|
||||||
{params.t("toolCall.diff.viewMode.split")}
|
<Copy class="h-4 w-4" aria-hidden="true" />
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class={`tool-call-diff-mode-button${diffMode() === "unified" ? " active" : ""}`}
|
class="file-viewer-toolbar-icon-button"
|
||||||
aria-pressed={diffMode() === "unified"}
|
onClick={() => handleModeChange(nextViewMode())}
|
||||||
onClick={() => handleModeChange("unified")}
|
aria-label={viewModeTitle()}
|
||||||
|
title={viewModeTitle()}
|
||||||
>
|
>
|
||||||
{params.t("toolCall.diff.viewMode.unified")}
|
{nextViewMode() === "split" ? <Split class="h-4 w-4" aria-hidden="true" /> : <AlignJustify class="h-4 w-4" aria-hidden="true" />}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class={`file-viewer-toolbar-icon-button${wordWrapEnabled() ? " active" : ""}`}
|
||||||
|
onClick={() => setWordWrapEnabled((enabled) => !enabled)}
|
||||||
|
aria-label={wordWrapTitle()}
|
||||||
|
title={wordWrapTitle()}
|
||||||
|
>
|
||||||
|
<WrapText class="h-4 w-4" aria-hidden="true" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{cachedHtml ? (
|
{cachedHtml() ? (
|
||||||
<CachedDiffMarkup html={cachedHtml} onRendered={handleDiffRendered} />
|
<CachedDiffMarkup html={cachedHtml()!} onRendered={handleDiffRendered} />
|
||||||
) : (
|
) : (
|
||||||
<Suspense fallback={<pre class="tool-call-diff-fallback">{payload.diffText}</pre>}>
|
<Suspense fallback={<pre class="tool-call-diff-fallback">{payload.diffText}</pre>}>
|
||||||
<LazyToolCallDiffViewer
|
<LazyToolCallDiffViewer
|
||||||
diffText={payload.diffText}
|
diffText={payload.diffText}
|
||||||
filePath={payload.filePath}
|
filePath={payload.filePath}
|
||||||
theme={themeKey}
|
theme={themeKey}
|
||||||
mode={diffMode()}
|
mode={currentMode()}
|
||||||
|
wrap={currentWrap()}
|
||||||
cacheEntryParams={cacheEntryParams as any}
|
cacheEntryParams={cacheEntryParams as any}
|
||||||
onRendered={handleDiffRendered}
|
onRendered={handleDiffRendered}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -41,6 +41,8 @@ export const remoteAccessMessages = {
|
|||||||
"remoteAccess.sections.addresses.help": "Launch or scan from another machine to hand over control.",
|
"remoteAccess.sections.addresses.help": "Launch or scan from another machine to hand over control.",
|
||||||
"remoteAccess.addresses.loading": "Loading addresses…",
|
"remoteAccess.addresses.loading": "Loading addresses…",
|
||||||
"remoteAccess.addresses.none": "No addresses available yet.",
|
"remoteAccess.addresses.none": "No addresses available yet.",
|
||||||
|
"remoteAccess.addresses.actions.showOther": "Show {count} other addresses",
|
||||||
|
"remoteAccess.addresses.actions.hideOther": "Hide other addresses",
|
||||||
"remoteAccess.address.scope.network": "Network",
|
"remoteAccess.address.scope.network": "Network",
|
||||||
"remoteAccess.address.scope.loopback": "Loopback",
|
"remoteAccess.address.scope.loopback": "Loopback",
|
||||||
"remoteAccess.address.scope.internal": "Internal",
|
"remoteAccess.address.scope.internal": "Internal",
|
||||||
|
|||||||
@@ -18,6 +18,11 @@ export const toolCallMessages = {
|
|||||||
"toolCall.diff.viewMode.ariaLabel": "Diff view mode",
|
"toolCall.diff.viewMode.ariaLabel": "Diff view mode",
|
||||||
"toolCall.diff.viewMode.split": "Split",
|
"toolCall.diff.viewMode.split": "Split",
|
||||||
"toolCall.diff.viewMode.unified": "Unified",
|
"toolCall.diff.viewMode.unified": "Unified",
|
||||||
|
"toolCall.diff.switchToSplit": "Switch to split view",
|
||||||
|
"toolCall.diff.switchToUnified": "Switch to unified view",
|
||||||
|
"toolCall.diff.enableWordWrap": "Enable word wrap",
|
||||||
|
"toolCall.diff.disableWordWrap": "Disable word wrap",
|
||||||
|
"toolCall.diff.copyPatch": "Copy patch",
|
||||||
|
|
||||||
"toolCall.diagnostics.title": "Diagnostics",
|
"toolCall.diagnostics.title": "Diagnostics",
|
||||||
"toolCall.diagnostics.ariaLabel": "Diagnostics",
|
"toolCall.diagnostics.ariaLabel": "Diagnostics",
|
||||||
|
|||||||
@@ -41,6 +41,8 @@ export const remoteAccessMessages = {
|
|||||||
"remoteAccess.sections.addresses.help": "Abre o escanea desde otra máquina para transferir el control.",
|
"remoteAccess.sections.addresses.help": "Abre o escanea desde otra máquina para transferir el control.",
|
||||||
"remoteAccess.addresses.loading": "Cargando direcciones…",
|
"remoteAccess.addresses.loading": "Cargando direcciones…",
|
||||||
"remoteAccess.addresses.none": "Aún no hay direcciones disponibles.",
|
"remoteAccess.addresses.none": "Aún no hay direcciones disponibles.",
|
||||||
|
"remoteAccess.addresses.actions.showOther": "Mostrar {count} direcciones más",
|
||||||
|
"remoteAccess.addresses.actions.hideOther": "Ocultar otras direcciones",
|
||||||
"remoteAccess.address.scope.network": "Red",
|
"remoteAccess.address.scope.network": "Red",
|
||||||
"remoteAccess.address.scope.loopback": "Loopback",
|
"remoteAccess.address.scope.loopback": "Loopback",
|
||||||
"remoteAccess.address.scope.internal": "Interna",
|
"remoteAccess.address.scope.internal": "Interna",
|
||||||
|
|||||||
@@ -18,6 +18,11 @@ export const toolCallMessages = {
|
|||||||
"toolCall.diff.viewMode.ariaLabel": "Modo de vista de diff",
|
"toolCall.diff.viewMode.ariaLabel": "Modo de vista de diff",
|
||||||
"toolCall.diff.viewMode.split": "Dividida",
|
"toolCall.diff.viewMode.split": "Dividida",
|
||||||
"toolCall.diff.viewMode.unified": "Unificada",
|
"toolCall.diff.viewMode.unified": "Unificada",
|
||||||
|
"toolCall.diff.switchToSplit": "Cambiar a vista dividida",
|
||||||
|
"toolCall.diff.switchToUnified": "Cambiar a vista unificada",
|
||||||
|
"toolCall.diff.enableWordWrap": "Activar ajuste de línea",
|
||||||
|
"toolCall.diff.disableWordWrap": "Desactivar ajuste de línea",
|
||||||
|
"toolCall.diff.copyPatch": "Copiar patch",
|
||||||
|
|
||||||
"toolCall.diagnostics.title": "Diagnósticos",
|
"toolCall.diagnostics.title": "Diagnósticos",
|
||||||
"toolCall.diagnostics.ariaLabel": "Diagnósticos",
|
"toolCall.diagnostics.ariaLabel": "Diagnósticos",
|
||||||
|
|||||||
@@ -41,6 +41,8 @@ export const remoteAccessMessages = {
|
|||||||
"remoteAccess.sections.addresses.help": "Lancez ou scannez depuis une autre machine pour passer le contrôle.",
|
"remoteAccess.sections.addresses.help": "Lancez ou scannez depuis une autre machine pour passer le contrôle.",
|
||||||
"remoteAccess.addresses.loading": "Chargement des adresses…",
|
"remoteAccess.addresses.loading": "Chargement des adresses…",
|
||||||
"remoteAccess.addresses.none": "Aucune adresse disponible pour le moment.",
|
"remoteAccess.addresses.none": "Aucune adresse disponible pour le moment.",
|
||||||
|
"remoteAccess.addresses.actions.showOther": "Afficher {count} autres adresses",
|
||||||
|
"remoteAccess.addresses.actions.hideOther": "Masquer les autres adresses",
|
||||||
"remoteAccess.address.scope.network": "Réseau",
|
"remoteAccess.address.scope.network": "Réseau",
|
||||||
"remoteAccess.address.scope.loopback": "Boucle locale",
|
"remoteAccess.address.scope.loopback": "Boucle locale",
|
||||||
"remoteAccess.address.scope.internal": "Interne",
|
"remoteAccess.address.scope.internal": "Interne",
|
||||||
|
|||||||
@@ -18,6 +18,11 @@ export const toolCallMessages = {
|
|||||||
"toolCall.diff.viewMode.ariaLabel": "Mode d'affichage du diff",
|
"toolCall.diff.viewMode.ariaLabel": "Mode d'affichage du diff",
|
||||||
"toolCall.diff.viewMode.split": "Côte à côte",
|
"toolCall.diff.viewMode.split": "Côte à côte",
|
||||||
"toolCall.diff.viewMode.unified": "Unifié",
|
"toolCall.diff.viewMode.unified": "Unifié",
|
||||||
|
"toolCall.diff.switchToSplit": "Passer à la vue côte à côte",
|
||||||
|
"toolCall.diff.switchToUnified": "Passer à la vue unifiée",
|
||||||
|
"toolCall.diff.enableWordWrap": "Activer le retour à la ligne",
|
||||||
|
"toolCall.diff.disableWordWrap": "Désactiver le retour à la ligne",
|
||||||
|
"toolCall.diff.copyPatch": "Copier le patch",
|
||||||
|
|
||||||
"toolCall.diagnostics.title": "Diagnostics",
|
"toolCall.diagnostics.title": "Diagnostics",
|
||||||
"toolCall.diagnostics.ariaLabel": "Diagnostics",
|
"toolCall.diagnostics.ariaLabel": "Diagnostics",
|
||||||
|
|||||||
@@ -41,6 +41,8 @@ export const remoteAccessMessages = {
|
|||||||
"remoteAccess.sections.addresses.help": "הפעל או סרוק ממכונה אחרת להעברת שליטה.",
|
"remoteAccess.sections.addresses.help": "הפעל או סרוק ממכונה אחרת להעברת שליטה.",
|
||||||
"remoteAccess.addresses.loading": "טוען כתובות…",
|
"remoteAccess.addresses.loading": "טוען כתובות…",
|
||||||
"remoteAccess.addresses.none": "אין כתובות זמינות עדיין.",
|
"remoteAccess.addresses.none": "אין כתובות זמינות עדיין.",
|
||||||
|
"remoteAccess.addresses.actions.showOther": "הצג עוד {count} כתובות",
|
||||||
|
"remoteAccess.addresses.actions.hideOther": "הסתר כתובות נוספות",
|
||||||
"remoteAccess.address.scope.network": "רשת",
|
"remoteAccess.address.scope.network": "רשת",
|
||||||
"remoteAccess.address.scope.loopback": "לולאה מקומית",
|
"remoteAccess.address.scope.loopback": "לולאה מקומית",
|
||||||
"remoteAccess.address.scope.internal": "פנימי",
|
"remoteAccess.address.scope.internal": "פנימי",
|
||||||
|
|||||||
@@ -18,6 +18,11 @@ export const toolCallMessages = {
|
|||||||
"toolCall.diff.viewMode.ariaLabel": "מצב תצוגת diff",
|
"toolCall.diff.viewMode.ariaLabel": "מצב תצוגת diff",
|
||||||
"toolCall.diff.viewMode.split": "מפוצל",
|
"toolCall.diff.viewMode.split": "מפוצל",
|
||||||
"toolCall.diff.viewMode.unified": "מאוחד",
|
"toolCall.diff.viewMode.unified": "מאוחד",
|
||||||
|
"toolCall.diff.switchToSplit": "עבור לתצוגה מפוצלת",
|
||||||
|
"toolCall.diff.switchToUnified": "עבור לתצוגה מאוחדת",
|
||||||
|
"toolCall.diff.enableWordWrap": "הפעל גלישת מילים",
|
||||||
|
"toolCall.diff.disableWordWrap": "כבה גלישת מילים",
|
||||||
|
"toolCall.diff.copyPatch": "העתק patch",
|
||||||
|
|
||||||
"toolCall.diagnostics.title": "אבחון",
|
"toolCall.diagnostics.title": "אבחון",
|
||||||
"toolCall.diagnostics.ariaLabel": "אבחון",
|
"toolCall.diagnostics.ariaLabel": "אבחון",
|
||||||
|
|||||||
@@ -41,6 +41,8 @@ export const remoteAccessMessages = {
|
|||||||
"remoteAccess.sections.addresses.help": "別の端末から起動またはスキャンして操作を引き継ぎます。",
|
"remoteAccess.sections.addresses.help": "別の端末から起動またはスキャンして操作を引き継ぎます。",
|
||||||
"remoteAccess.addresses.loading": "アドレスを読み込み中…",
|
"remoteAccess.addresses.loading": "アドレスを読み込み中…",
|
||||||
"remoteAccess.addresses.none": "まだ利用可能なアドレスがありません。",
|
"remoteAccess.addresses.none": "まだ利用可能なアドレスがありません。",
|
||||||
|
"remoteAccess.addresses.actions.showOther": "他の {count} 件のアドレスを表示",
|
||||||
|
"remoteAccess.addresses.actions.hideOther": "他のアドレスを隠す",
|
||||||
"remoteAccess.address.scope.network": "ネットワーク",
|
"remoteAccess.address.scope.network": "ネットワーク",
|
||||||
"remoteAccess.address.scope.loopback": "ループバック",
|
"remoteAccess.address.scope.loopback": "ループバック",
|
||||||
"remoteAccess.address.scope.internal": "内部",
|
"remoteAccess.address.scope.internal": "内部",
|
||||||
|
|||||||
@@ -18,6 +18,11 @@ export const toolCallMessages = {
|
|||||||
"toolCall.diff.viewMode.ariaLabel": "diff 表示モード",
|
"toolCall.diff.viewMode.ariaLabel": "diff 表示モード",
|
||||||
"toolCall.diff.viewMode.split": "分割",
|
"toolCall.diff.viewMode.split": "分割",
|
||||||
"toolCall.diff.viewMode.unified": "ユニファイド",
|
"toolCall.diff.viewMode.unified": "ユニファイド",
|
||||||
|
"toolCall.diff.switchToSplit": "分割表示に切り替え",
|
||||||
|
"toolCall.diff.switchToUnified": "ユニファイド表示に切り替え",
|
||||||
|
"toolCall.diff.enableWordWrap": "折り返しを有効化",
|
||||||
|
"toolCall.diff.disableWordWrap": "折り返しを無効化",
|
||||||
|
"toolCall.diff.copyPatch": "パッチをコピー",
|
||||||
|
|
||||||
"toolCall.diagnostics.title": "診断",
|
"toolCall.diagnostics.title": "診断",
|
||||||
"toolCall.diagnostics.ariaLabel": "診断",
|
"toolCall.diagnostics.ariaLabel": "診断",
|
||||||
|
|||||||
@@ -41,6 +41,8 @@ export const remoteAccessMessages = {
|
|||||||
"remoteAccess.sections.addresses.help": "Откройте или отсканируйте с другой машины, чтобы передать управление.",
|
"remoteAccess.sections.addresses.help": "Откройте или отсканируйте с другой машины, чтобы передать управление.",
|
||||||
"remoteAccess.addresses.loading": "Загрузка адресов…",
|
"remoteAccess.addresses.loading": "Загрузка адресов…",
|
||||||
"remoteAccess.addresses.none": "Пока нет доступных адресов.",
|
"remoteAccess.addresses.none": "Пока нет доступных адресов.",
|
||||||
|
"remoteAccess.addresses.actions.showOther": "Показать еще {count} адресов",
|
||||||
|
"remoteAccess.addresses.actions.hideOther": "Скрыть остальные адреса",
|
||||||
"remoteAccess.address.scope.network": "Сеть",
|
"remoteAccess.address.scope.network": "Сеть",
|
||||||
"remoteAccess.address.scope.loopback": "Loopback",
|
"remoteAccess.address.scope.loopback": "Loopback",
|
||||||
"remoteAccess.address.scope.internal": "Внутренний",
|
"remoteAccess.address.scope.internal": "Внутренний",
|
||||||
|
|||||||
@@ -18,6 +18,11 @@ export const toolCallMessages = {
|
|||||||
"toolCall.diff.viewMode.ariaLabel": "Режим просмотра diff",
|
"toolCall.diff.viewMode.ariaLabel": "Режим просмотра diff",
|
||||||
"toolCall.diff.viewMode.split": "Раздельный",
|
"toolCall.diff.viewMode.split": "Раздельный",
|
||||||
"toolCall.diff.viewMode.unified": "Единый",
|
"toolCall.diff.viewMode.unified": "Единый",
|
||||||
|
"toolCall.diff.switchToSplit": "Переключить на раздельный вид",
|
||||||
|
"toolCall.diff.switchToUnified": "Переключить на единый вид",
|
||||||
|
"toolCall.diff.enableWordWrap": "Включить перенос слов",
|
||||||
|
"toolCall.diff.disableWordWrap": "Выключить перенос слов",
|
||||||
|
"toolCall.diff.copyPatch": "Скопировать patch",
|
||||||
|
|
||||||
"toolCall.diagnostics.title": "Диагностика",
|
"toolCall.diagnostics.title": "Диагностика",
|
||||||
"toolCall.diagnostics.ariaLabel": "Диагностика",
|
"toolCall.diagnostics.ariaLabel": "Диагностика",
|
||||||
|
|||||||
@@ -41,6 +41,8 @@ export const remoteAccessMessages = {
|
|||||||
"remoteAccess.sections.addresses.help": "从另一台设备打开或扫描,以接管控制权。",
|
"remoteAccess.sections.addresses.help": "从另一台设备打开或扫描,以接管控制权。",
|
||||||
"remoteAccess.addresses.loading": "正在加载地址…",
|
"remoteAccess.addresses.loading": "正在加载地址…",
|
||||||
"remoteAccess.addresses.none": "暂时没有可用地址。",
|
"remoteAccess.addresses.none": "暂时没有可用地址。",
|
||||||
|
"remoteAccess.addresses.actions.showOther": "显示另外 {count} 个地址",
|
||||||
|
"remoteAccess.addresses.actions.hideOther": "隐藏其他地址",
|
||||||
"remoteAccess.address.scope.network": "网络",
|
"remoteAccess.address.scope.network": "网络",
|
||||||
"remoteAccess.address.scope.loopback": "回环",
|
"remoteAccess.address.scope.loopback": "回环",
|
||||||
"remoteAccess.address.scope.internal": "内部",
|
"remoteAccess.address.scope.internal": "内部",
|
||||||
|
|||||||
@@ -18,6 +18,11 @@ export const toolCallMessages = {
|
|||||||
"toolCall.diff.viewMode.ariaLabel": "Diff 视图模式",
|
"toolCall.diff.viewMode.ariaLabel": "Diff 视图模式",
|
||||||
"toolCall.diff.viewMode.split": "分栏",
|
"toolCall.diff.viewMode.split": "分栏",
|
||||||
"toolCall.diff.viewMode.unified": "统一",
|
"toolCall.diff.viewMode.unified": "统一",
|
||||||
|
"toolCall.diff.switchToSplit": "切换到分栏视图",
|
||||||
|
"toolCall.diff.switchToUnified": "切换到统一视图",
|
||||||
|
"toolCall.diff.enableWordWrap": "启用自动换行",
|
||||||
|
"toolCall.diff.disableWordWrap": "禁用自动换行",
|
||||||
|
"toolCall.diff.copyPatch": "复制补丁",
|
||||||
|
|
||||||
"toolCall.diagnostics.title": "诊断",
|
"toolCall.diagnostics.title": "诊断",
|
||||||
"toolCall.diagnostics.ariaLabel": "诊断",
|
"toolCall.diagnostics.ariaLabel": "诊断",
|
||||||
|
|||||||
17
packages/ui/src/lib/remote-access-addresses.test.ts
Normal file
17
packages/ui/src/lib/remote-access-addresses.test.ts
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
import assert from "node:assert/strict"
|
||||||
|
import { describe, it } from "node:test"
|
||||||
|
|
||||||
|
import { splitRemoteAddresses } from "./remote-access-addresses"
|
||||||
|
|
||||||
|
describe("splitRemoteAddresses", () => {
|
||||||
|
it("keeps the first remote address visible and collapses the rest", () => {
|
||||||
|
const result = splitRemoteAddresses([
|
||||||
|
{ ip: "127.0.0.1", family: "ipv4", scope: "loopback", remoteUrl: "https://127.0.0.1:9898" },
|
||||||
|
{ ip: "192.168.1.128", family: "ipv4", scope: "external", remoteUrl: "https://192.168.1.128:9898" },
|
||||||
|
{ ip: "172.24.96.1", family: "ipv4", scope: "external", remoteUrl: "https://172.24.96.1:9898" },
|
||||||
|
])
|
||||||
|
|
||||||
|
assert.equal(result.recommended?.ip, "192.168.1.128")
|
||||||
|
assert.deepEqual(result.hidden.map((address) => address.ip), ["172.24.96.1"])
|
||||||
|
})
|
||||||
|
})
|
||||||
14
packages/ui/src/lib/remote-access-addresses.ts
Normal file
14
packages/ui/src/lib/remote-access-addresses.ts
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
import type { NetworkAddress } from "../../../server/src/api-types"
|
||||||
|
|
||||||
|
export interface RemoteAddressGroups {
|
||||||
|
recommended: NetworkAddress | null
|
||||||
|
hidden: NetworkAddress[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export function splitRemoteAddresses(addresses: NetworkAddress[]): RemoteAddressGroups {
|
||||||
|
const remoteAddresses = addresses.filter((address) => address.scope !== "loopback")
|
||||||
|
return {
|
||||||
|
recommended: remoteAddresses[0] ?? null,
|
||||||
|
hidden: remoteAddresses.slice(1),
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -256,6 +256,55 @@
|
|||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.remote-address-disclosure {
|
||||||
|
border: 1px solid var(--border-base);
|
||||||
|
border-radius: 12px;
|
||||||
|
background: var(--surface-primary);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.remote-address-disclosure-trigger {
|
||||||
|
width: 100%;
|
||||||
|
min-height: 40px;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr auto 1fr;
|
||||||
|
align-items: center;
|
||||||
|
padding: 8px 12px;
|
||||||
|
border: 0;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--text-primary);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.remote-address-disclosure-label {
|
||||||
|
grid-column: 2;
|
||||||
|
justify-self: center;
|
||||||
|
text-align: center;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.remote-address-disclosure-chevron {
|
||||||
|
grid-column: 3;
|
||||||
|
justify-self: end;
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
transition: transform 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.remote-address-disclosure-chevron.is-expanded {
|
||||||
|
transform: rotate(180deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.remote-address-disclosure-content {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 0 10px 10px;
|
||||||
|
border-top: 1px solid var(--border-base);
|
||||||
|
}
|
||||||
|
|
||||||
.remote-qr {
|
.remote-qr {
|
||||||
margin-top: 12px;
|
margin-top: 12px;
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
.settings-screen-frame {
|
.settings-screen-frame {
|
||||||
@apply fixed inset-0 z-50 flex items-center justify-center p-4;
|
@apply fixed inset-0 z-50 flex items-center justify-center px-4;
|
||||||
|
padding-block: 5dvh;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Override .modal-surface (defined later in panels.css). */
|
/* Override .modal-surface (defined later in panels.css). */
|
||||||
.modal-surface.settings-screen-shell {
|
.modal-surface.settings-screen-shell {
|
||||||
width: min(1120px, 100%);
|
width: min(1120px, 100%);
|
||||||
height: min(88vh, 920px);
|
height: 100%;
|
||||||
max-height: none;
|
max-height: none;
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: minmax(220px, 260px) minmax(0, 1fr);
|
grid-template-columns: minmax(220px, 260px) minmax(0, 1fr);
|
||||||
@@ -278,10 +279,25 @@
|
|||||||
font-size: var(--font-size-sm);
|
font-size: var(--font-size-sm);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.settings-password-summary-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 1rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-password-summary-copy {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.25rem;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
.settings-password-actions {
|
.settings-password-actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: flex-start;
|
justify-content: flex-end;
|
||||||
margin-top: 0.75rem;
|
margin-top: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.settings-form-group {
|
.settings-form-group {
|
||||||
|
|||||||
@@ -321,6 +321,7 @@
|
|||||||
|
|
||||||
.tool-call-diff-shell {
|
.tool-call-diff-shell {
|
||||||
padding: 0;
|
padding: 0;
|
||||||
|
scrollbar-gutter: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tool-call-diff-viewer {
|
.tool-call-diff-viewer {
|
||||||
@@ -343,6 +344,8 @@
|
|||||||
.tool-call-diff-shell .tool-call-diff-viewer {
|
.tool-call-diff-shell .tool-call-diff-viewer {
|
||||||
max-height: none;
|
max-height: none;
|
||||||
overflow: visible;
|
overflow: visible;
|
||||||
|
width: 100%;
|
||||||
|
min-width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tool-call-diff-toolbar-label {
|
.tool-call-diff-toolbar-label {
|
||||||
@@ -513,6 +516,84 @@
|
|||||||
font-size: var(--font-size-xs);
|
font-size: var(--font-size-xs);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.tool-call-diff-shell .tool-call-diff-viewer .diff-line-content,
|
||||||
|
.tool-call-diff-shell .tool-call-diff-viewer .diff-line-hunk-content,
|
||||||
|
.tool-call-diff-shell .tool-call-diff-viewer .diff-line-old-content,
|
||||||
|
.tool-call-diff-shell .tool-call-diff-viewer .diff-line-new-content {
|
||||||
|
padding-right: 0 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tool-call-diff-shell[data-diff-mode="unified"] .tool-call-diff-viewer .diff-line-num {
|
||||||
|
padding-left: 1px !important;
|
||||||
|
padding-right: 1px !important;
|
||||||
|
text-align: left !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tool-call-diff-shell[data-diff-mode="unified"] .tool-call-diff-viewer .unified-diff-table {
|
||||||
|
table-layout: fixed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tool-call-diff-shell[data-diff-mode="unified"] .tool-call-diff-viewer .unified-diff-table-num-col {
|
||||||
|
width: auto !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tool-call-diff-shell[data-diff-mode="unified"] .tool-call-diff-viewer .tool-call-diff-compact-line-number {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
text-align: left;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tool-call-diff-shell[data-diff-mode="split"] .tool-call-diff-viewer .diff-line-old-num,
|
||||||
|
.tool-call-diff-shell[data-diff-mode="split"] .tool-call-diff-viewer .diff-line-new-num,
|
||||||
|
.tool-call-diff-shell[data-diff-mode="split"] .tool-call-diff-viewer .diff-line-hunk-action {
|
||||||
|
padding-left: 2px !important;
|
||||||
|
padding-right: 2px !important;
|
||||||
|
text-align: left !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tool-call-diff-shell[data-diff-mode="split"] .tool-call-diff-viewer .diff-line-old-num,
|
||||||
|
.tool-call-diff-shell[data-diff-mode="split"] .tool-call-diff-viewer .diff-line-new-num,
|
||||||
|
.tool-call-diff-shell[data-diff-mode="split"] .tool-call-diff-viewer .diff-line-old-num [data-line-num],
|
||||||
|
.tool-call-diff-shell[data-diff-mode="split"] .tool-call-diff-viewer .diff-line-new-num [data-line-num] {
|
||||||
|
white-space: nowrap !important;
|
||||||
|
word-break: normal !important;
|
||||||
|
overflow-wrap: normal !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tool-call-diff-shell[data-diff-mode="split"] .tool-call-diff-viewer .diff-line-hunk-action {
|
||||||
|
padding-top: 1px !important;
|
||||||
|
padding-bottom: 1px !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tool-call-diff-shell .tool-call-diff-viewer .diff-line-content-item {
|
||||||
|
padding-left: 1.1em !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tool-call-diff-shell .tool-call-diff-viewer .diff-line-content-operator {
|
||||||
|
margin-left: -1.1em !important;
|
||||||
|
width: 0.9em !important;
|
||||||
|
min-width: 0.9em !important;
|
||||||
|
text-indent: 0 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.tool-call-diff-shell[data-diff-mode="unified"] .tool-call-diff-viewer .unified-diff-table-wrapper {
|
||||||
|
--diff-aside-width: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tool-call-diff-shell .tool-call-diff-viewer .diff-line-content-item {
|
||||||
|
padding-left: 1.5em !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tool-call-diff-shell .tool-call-diff-viewer .diff-line-content-operator {
|
||||||
|
margin-left: -1.5em !important;
|
||||||
|
width: 1.1em !important;
|
||||||
|
min-width: 1.1em !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.tool-call-markdown .markdown-code-block {
|
.tool-call-markdown .markdown-code-block {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
border-radius: 0;
|
border-radius: 0;
|
||||||
|
|||||||
@@ -40,6 +40,7 @@ export interface RenderCache {
|
|||||||
html: string
|
html: string
|
||||||
theme?: string
|
theme?: string
|
||||||
mode?: string
|
mode?: string
|
||||||
|
wrap?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PendingPermissionState {
|
export interface PendingPermissionState {
|
||||||
|
|||||||
Reference in New Issue
Block a user