Compare commits
27 Commits
v0.13.3-de
...
feat/linux
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d0ddefc168 | ||
|
|
d456ae5837 | ||
|
|
3ec1598bbd | ||
|
|
ec3b418934 | ||
|
|
99b2066923 | ||
|
|
c2c88e956e | ||
|
|
aa69d2c1f1 | ||
|
|
e965754d4c | ||
|
|
efe5c455e0 | ||
|
|
be4f383602 | ||
|
|
adcaf3a116 | ||
|
|
a795869064 | ||
|
|
9bf4d351de | ||
|
|
657e78da6a | ||
|
|
dee356558f | ||
|
|
03ed3d3b2c | ||
|
|
a111de1af8 | ||
|
|
8a3b162be9 | ||
|
|
c62cb3ce4a | ||
|
|
d9811e735d | ||
|
|
1ce58b9dd9 | ||
|
|
1907a4da03 | ||
|
|
abf4c67fcc | ||
|
|
bc130ceb5b | ||
|
|
8505a43b16 | ||
|
|
2a3329b5ed | ||
|
|
c9c1cf21f0 |
8
.github/workflows/build-and-upload.yml
vendored
8
.github/workflows/build-and-upload.yml
vendored
@@ -212,7 +212,7 @@ jobs:
|
||||
run: |
|
||||
set -euo pipefail
|
||||
shopt -s nullglob
|
||||
for file in packages/electron-app/release/*.zip; do
|
||||
for file in packages/electron-app/release/*.zip packages/electron-app/release/*.AppImage; do
|
||||
[ -f "$file" ] || continue
|
||||
echo "Uploading $file"
|
||||
gh release upload "$TAG" "$file" --clobber
|
||||
@@ -313,7 +313,7 @@ jobs:
|
||||
run: |
|
||||
set -euo pipefail
|
||||
shopt -s nullglob
|
||||
for file in packages/electron-app/release/*.zip; do
|
||||
for file in packages/electron-app/release/*.zip packages/electron-app/release/*.AppImage; do
|
||||
[ -f "$file" ] || continue
|
||||
echo "Uploading $file"
|
||||
gh release upload "$TAG" "$file" --clobber
|
||||
@@ -324,7 +324,9 @@ jobs:
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: ${{ inputs.actions_artifacts_name_prefix }}electron-linux
|
||||
path: packages/electron-app/release/*.zip
|
||||
path: |
|
||||
packages/electron-app/release/*.zip
|
||||
packages/electron-app/release/*.AppImage
|
||||
retention-days: ${{ inputs.actions_artifacts_retention_days }}
|
||||
if-no-files-found: error
|
||||
|
||||
|
||||
55
README.md
55
README.md
@@ -18,6 +18,7 @@ CodeNomad transforms OpenCode from a terminal tool into a **premium desktop work
|
||||
- **🎙️ Voice Input & Speech**
|
||||
- **🌳 Git Worktrees**
|
||||
- **💬 Rich Message Experience**
|
||||
- **🧩 SideCars**
|
||||
- **⌨️ Command Palette**
|
||||
- **📁 File System Browser**
|
||||
- **🔐 Authentication & Security**
|
||||
@@ -61,6 +62,60 @@ npx @neuralnomads/codenomad-dev --launch
|
||||
|
||||
---
|
||||
|
||||
## SideCars
|
||||
|
||||
SideCars let you open local web tools inside CodeNomad as tabs.
|
||||
|
||||
<details>
|
||||
<summary><strong>Configuration</strong></summary>
|
||||
|
||||
- **Name**: Display name used in CodeNomad
|
||||
- **Port**: Local HTTP or HTTPS service running on `127.0.0.1:<port>`
|
||||
- **Base path**: Mounted under `/sidecars/:id`
|
||||
- **Prefix mode**:
|
||||
- **Preserve prefix** forwards the full `/sidecars/:id/...` path upstream
|
||||
- **Strip prefix** removes `/sidecars/:id` before forwarding the request upstream
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><strong>VSCode (OpenVSCode Server)</strong></summary>
|
||||
|
||||
Run with Docker:
|
||||
|
||||
```bash
|
||||
docker run -it --init -p 8000:3000 -v "${HOME}:${HOME}:cached" -e HOME=${HOME} gitpod/openvscode-server --server-base-path /sidecars/vscode
|
||||
```
|
||||
|
||||
Add SideCar as:
|
||||
|
||||
- **Name**: `VSCode`
|
||||
- **Port**: `http://127.0.0.1:8000`
|
||||
- **Base path**: `/sidecars/vscode`
|
||||
- **Prefix mode**: `Preserve prefix`
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><strong>Terminal (ttyd)</strong></summary>
|
||||
|
||||
Run with:
|
||||
|
||||
```bash
|
||||
ttyd --writable zsh
|
||||
```
|
||||
|
||||
Add SideCar as:
|
||||
|
||||
- **Name**: `Terminal`
|
||||
- **Port**: `http://127.0.0.1:7681`
|
||||
- **Base path**: `/sidecars/terminal`
|
||||
- **Prefix mode**: `Strip prefix`
|
||||
|
||||
</details>
|
||||
|
||||
---
|
||||
|
||||
## Requirements
|
||||
|
||||
- **[OpenCode CLI](https://opencode.ai)** — must be installed and in your `PATH`
|
||||
|
||||
1188
package-lock.json
generated
1188
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "codenomad-workspace",
|
||||
"version": "0.13.3",
|
||||
"version": "0.14.0",
|
||||
"private": true,
|
||||
"description": "CodeNomad monorepo workspace",
|
||||
"license": "MIT",
|
||||
@@ -29,6 +29,8 @@
|
||||
"google-auth-library": "^10.5.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@esbuild/darwin-arm64": "^0.28.0",
|
||||
"@rollup/rollup-darwin-arm64": "^4.60.2",
|
||||
"baseline-browser-mapping": "^2.9.11"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
{
|
||||
"minServerVersion": "0.13.3",
|
||||
"minServerVersion": "0.14.0",
|
||||
"latestServerUrl": "https://github.com/NeuralNomadsAI/CodeNomad/releases/latest"
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@neuralnomads/codenomad-electron-app",
|
||||
"version": "0.13.3",
|
||||
"version": "0.14.0",
|
||||
"description": "CodeNomad - AI coding assistant",
|
||||
"license": "MIT",
|
||||
"author": {
|
||||
@@ -147,6 +147,13 @@
|
||||
"x64",
|
||||
"arm64"
|
||||
]
|
||||
},
|
||||
{
|
||||
"target": "AppImage",
|
||||
"arch": [
|
||||
"x64",
|
||||
"arm64"
|
||||
]
|
||||
}
|
||||
],
|
||||
"artifactName": "CodeNomad-${version}-${os}-${arch}.${ext}",
|
||||
|
||||
4
packages/server/package-lock.json
generated
4
packages/server/package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "@neuralnomads/codenomad",
|
||||
"version": "0.13.3",
|
||||
"version": "0.14.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@neuralnomads/codenomad",
|
||||
"version": "0.13.3",
|
||||
"version": "0.14.0",
|
||||
"dependencies": {
|
||||
"@fastify/cors": "^8.5.0",
|
||||
"@fastify/reply-from": "^9.8.0",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@neuralnomads/codenomad",
|
||||
"version": "0.13.3",
|
||||
"version": "0.14.0",
|
||||
"description": "CodeNomad Server",
|
||||
"license": "MIT",
|
||||
"author": {
|
||||
|
||||
@@ -81,6 +81,55 @@ export interface WorktreeMap {
|
||||
parentSessionWorktreeSlug: Record<string, string>
|
||||
}
|
||||
|
||||
export type GitChangeKind = "added" | "modified" | "deleted" | "renamed" | "copied" | "untracked" | "unmerged"
|
||||
|
||||
export interface WorktreeGitStatusEntry {
|
||||
path: string
|
||||
originalPath?: string | null
|
||||
stagedStatus: GitChangeKind | null
|
||||
stagedAdditions: number
|
||||
stagedDeletions: number
|
||||
unstagedStatus: GitChangeKind | null
|
||||
unstagedAdditions: number
|
||||
unstagedDeletions: number
|
||||
}
|
||||
|
||||
export type WorktreeGitStatusResponse = WorktreeGitStatusEntry[]
|
||||
|
||||
export type WorktreeGitDiffScope = "staged" | "unstaged"
|
||||
|
||||
export interface WorktreeGitPathsRequest {
|
||||
paths: string[]
|
||||
}
|
||||
|
||||
export interface WorktreeGitMutationResponse {
|
||||
ok: true
|
||||
}
|
||||
|
||||
export interface WorktreeGitCommitRequest {
|
||||
message: string
|
||||
}
|
||||
|
||||
export interface WorktreeGitCommitResponse {
|
||||
ok: true
|
||||
commitSha?: string
|
||||
}
|
||||
|
||||
export interface WorktreeGitDiffResponse {
|
||||
path: string
|
||||
originalPath?: string | null
|
||||
scope: WorktreeGitDiffScope
|
||||
before: string
|
||||
after: string
|
||||
isBinary?: boolean
|
||||
}
|
||||
|
||||
export interface WorktreeGitDiffRequest {
|
||||
path: string
|
||||
originalPath?: string | null
|
||||
scope: WorktreeGitDiffScope
|
||||
}
|
||||
|
||||
export type LogLevel = "debug" | "info" | "warn" | "error"
|
||||
|
||||
export interface WorkspaceLogEntry {
|
||||
@@ -288,6 +337,15 @@ export interface RemoteServerProbeResponse {
|
||||
errorCode?: string
|
||||
}
|
||||
|
||||
export interface RemoteProxySessionCreateRequest {
|
||||
baseUrl: string
|
||||
skipTlsVerify?: boolean
|
||||
}
|
||||
|
||||
export interface RemoteProxySessionCreateResponse {
|
||||
windowUrl: string
|
||||
}
|
||||
|
||||
export type WorkspaceEventType =
|
||||
| "workspace.created"
|
||||
| "workspace.started"
|
||||
|
||||
@@ -21,10 +21,14 @@ import { launchInBrowser } from "./launcher"
|
||||
import { resolveUi } from "./ui/remote-ui"
|
||||
import { AuthManager, BOOTSTRAP_TOKEN_STDOUT_PREFIX, DEFAULT_AUTH_COOKIE_NAME, DEFAULT_AUTH_USERNAME } from "./auth/manager"
|
||||
import { resolveHttpsOptions } from "./server/tls"
|
||||
import { RemoteProxySessionManager } from "./server/remote-proxy"
|
||||
import { resolveNetworkAddresses, resolveRemoteAddresses } from "./server/network-addresses"
|
||||
import { startDevReleaseMonitor } from "./releases/dev-release-monitor"
|
||||
import { SpeechService } from "./speech/service"
|
||||
import { SideCarManager } from "./sidecars/manager"
|
||||
import { ClientConnectionManager } from "./clients/connection-manager"
|
||||
import { PluginChannelManager } from "./plugins/channel"
|
||||
import { VoiceModeManager } from "./plugins/voice-mode"
|
||||
|
||||
const require = createRequire(import.meta.url)
|
||||
|
||||
@@ -378,6 +382,19 @@ async function main() {
|
||||
|
||||
const remoteAccessEnabled = options.host === "0.0.0.0" || !isLoopbackHost(options.host)
|
||||
|
||||
const clientConnectionManager = new ClientConnectionManager(logger.child({ component: "client-connections" }))
|
||||
const pluginChannel = new PluginChannelManager(logger.child({ component: "plugin-channel" }))
|
||||
const remoteProxySessionManager = new RemoteProxySessionManager({
|
||||
authManager,
|
||||
logger: logger.child({ component: "remote-proxy" }),
|
||||
httpsOptions: tlsResolution?.httpsOptions,
|
||||
})
|
||||
const voiceModeManager = new VoiceModeManager({
|
||||
connections: clientConnectionManager,
|
||||
channel: pluginChannel,
|
||||
logger: logger.child({ component: "voice-mode" }),
|
||||
})
|
||||
|
||||
const httpsPortExplicit = programHasArg(process.argv.slice(2), "--https-port") || Boolean(process.env.CLI_HTTPS_PORT)
|
||||
const httpPortExplicit = programHasArg(process.argv.slice(2), "--http-port") || Boolean(process.env.CLI_HTTP_PORT)
|
||||
|
||||
@@ -408,6 +425,10 @@ async function main() {
|
||||
speechService,
|
||||
sidecarManager,
|
||||
authManager,
|
||||
clientConnectionManager,
|
||||
pluginChannel,
|
||||
voiceModeManager,
|
||||
remoteProxySessionManager,
|
||||
uiStaticDir: uiResolution.uiStaticDir ?? DEFAULT_UI_STATIC_DIR,
|
||||
uiDevServerUrl: uiResolution.uiDevServerUrl,
|
||||
logger,
|
||||
@@ -430,6 +451,10 @@ async function main() {
|
||||
speechService,
|
||||
sidecarManager,
|
||||
authManager,
|
||||
clientConnectionManager,
|
||||
pluginChannel,
|
||||
voiceModeManager,
|
||||
remoteProxySessionManager,
|
||||
uiStaticDir: uiResolution.uiStaticDir ?? DEFAULT_UI_STATIC_DIR,
|
||||
uiDevServerUrl: undefined,
|
||||
logger,
|
||||
@@ -534,6 +559,12 @@ async function main() {
|
||||
logger.error({ err: error }, "SideCar manager shutdown failed")
|
||||
}
|
||||
|
||||
try {
|
||||
clientConnectionManager.shutdown()
|
||||
} catch (error) {
|
||||
logger.warn({ err: error }, "Client connection manager shutdown failed")
|
||||
}
|
||||
|
||||
try {
|
||||
await workspaceManager.shutdown()
|
||||
logger.info("Workspace manager shutdown complete")
|
||||
|
||||
@@ -19,13 +19,13 @@ export class VoiceModeManager {
|
||||
})
|
||||
}
|
||||
|
||||
setEnabled(instanceId: string, connection: ClientConnectionRef, enabled: boolean): void {
|
||||
setEnabled(instanceId: string, connection: ClientConnectionRef, enabled: boolean): boolean {
|
||||
if (enabled && !this.options.connections.isConnected(connection)) {
|
||||
this.options.logger.debug(
|
||||
{ instanceId, clientId: connection.clientId, connectionId: connection.connectionId },
|
||||
"Ignoring voice mode enable for disconnected client connection",
|
||||
)
|
||||
return
|
||||
return false
|
||||
}
|
||||
|
||||
const key = getConnectionKey(connection)
|
||||
@@ -44,6 +44,7 @@ export class VoiceModeManager {
|
||||
|
||||
this.options.logger.debug({ instanceId, clientId: connection.clientId, connectionId: connection.connectionId, enabled }, "Voice mode updated for client connection")
|
||||
this.publishIfChanged(instanceId)
|
||||
return true
|
||||
}
|
||||
|
||||
syncInstance(instanceId: string): void {
|
||||
@@ -76,7 +77,10 @@ export class VoiceModeManager {
|
||||
this.aggregateByInstance.delete(instanceId)
|
||||
}
|
||||
|
||||
this.options.logger.debug({ instanceId, enabled }, "Broadcasting aggregate voice mode")
|
||||
this.options.logger.debug(
|
||||
{ instanceId, enabled },
|
||||
"Broadcasting aggregate voice mode",
|
||||
)
|
||||
this.options.channel.send(instanceId, buildVoiceModeEvent(enabled))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ import { fetch } from "undici"
|
||||
import type { Logger } from "../logger"
|
||||
import { WorkspaceManager } from "../workspaces/manager"
|
||||
import { isValidWorktreeSlug, listWorktrees, resolveRepoRoot } from "../workspaces/git-worktrees"
|
||||
import { resolveWorktreeDirectory } from "../workspaces/worktree-directory"
|
||||
|
||||
import type { SettingsService } from "../settings/service"
|
||||
import { FileSystemBrowser } from "../filesystem/browser"
|
||||
@@ -25,6 +26,7 @@ import { registerBackgroundProcessRoutes } from "./routes/background-processes"
|
||||
import { registerWorktreeRoutes } from "./routes/worktrees"
|
||||
import { registerSpeechRoutes } from "./routes/speech"
|
||||
import { registerRemoteServerRoutes } from "./routes/remote-servers"
|
||||
import { registerRemoteProxyRoutes } from "./routes/remote-proxy"
|
||||
import { registerSideCarRoutes } from "./routes/sidecars"
|
||||
import { ServerMeta } from "../api-types"
|
||||
import { InstanceStore } from "../storage/instance-store"
|
||||
@@ -37,6 +39,7 @@ import { ClientConnectionManager } from "../clients/connection-manager"
|
||||
import { PluginChannelManager } from "../plugins/channel"
|
||||
import { VoiceModeManager } from "../plugins/voice-mode"
|
||||
import type { SideCarManager } from "../sidecars/manager"
|
||||
import type { RemoteProxySessionManager } from "./remote-proxy"
|
||||
|
||||
interface HttpServerDeps {
|
||||
bindHost: string
|
||||
@@ -54,6 +57,10 @@ interface HttpServerDeps {
|
||||
speechService: SpeechService
|
||||
sidecarManager: SideCarManager
|
||||
authManager: AuthManager
|
||||
clientConnectionManager: ClientConnectionManager
|
||||
pluginChannel: PluginChannelManager
|
||||
voiceModeManager: VoiceModeManager
|
||||
remoteProxySessionManager: RemoteProxySessionManager
|
||||
uiStaticDir: string
|
||||
uiDevServerUrl?: string
|
||||
logger: Logger
|
||||
@@ -182,13 +189,6 @@ export function createHttpServer(deps: HttpServerDeps) {
|
||||
eventBus: deps.eventBus,
|
||||
logger: deps.logger.child({ component: "background-processes" }),
|
||||
})
|
||||
const clientConnectionManager = new ClientConnectionManager(deps.logger.child({ component: "client-connections" }))
|
||||
const pluginChannel = new PluginChannelManager(deps.logger.child({ component: "plugin-channel" }))
|
||||
const voiceModeManager = new VoiceModeManager({
|
||||
connections: clientConnectionManager,
|
||||
channel: pluginChannel,
|
||||
logger: deps.logger.child({ component: "voice-mode" }),
|
||||
})
|
||||
|
||||
registerAuthRoutes(app, { authManager: deps.authManager })
|
||||
|
||||
@@ -268,7 +268,7 @@ export function createHttpServer(deps: HttpServerDeps) {
|
||||
eventBus: deps.eventBus,
|
||||
registerClient: registerSseClient,
|
||||
logger: sseLogger,
|
||||
connectionManager: clientConnectionManager,
|
||||
connectionManager: deps.clientConnectionManager,
|
||||
})
|
||||
registerWorktreeRoutes(app, { workspaceManager: deps.workspaceManager })
|
||||
registerStorageRoutes(app, {
|
||||
@@ -277,6 +277,7 @@ export function createHttpServer(deps: HttpServerDeps) {
|
||||
workspaceManager: deps.workspaceManager,
|
||||
})
|
||||
registerRemoteServerRoutes(app, { logger: apiLogger })
|
||||
registerRemoteProxyRoutes(app, { logger: proxyLogger, sessionManager: deps.remoteProxySessionManager })
|
||||
registerSpeechRoutes(app, { speechService: deps.speechService })
|
||||
registerSideCarRoutes(app, { sidecarManager: deps.sidecarManager })
|
||||
registerSideCarProxyRoutes(app, { sidecarManager: deps.sidecarManager, logger: proxyLogger })
|
||||
@@ -289,8 +290,8 @@ export function createHttpServer(deps: HttpServerDeps) {
|
||||
workspaceManager: deps.workspaceManager,
|
||||
eventBus: deps.eventBus,
|
||||
logger: proxyLogger,
|
||||
channel: pluginChannel,
|
||||
voiceModeManager,
|
||||
channel: deps.pluginChannel,
|
||||
voiceModeManager: deps.voiceModeManager,
|
||||
})
|
||||
registerBackgroundProcessRoutes(app, { backgroundProcessManager })
|
||||
registerInstanceProxyRoutes(app, { workspaceManager: deps.workspaceManager, logger: proxyLogger })
|
||||
@@ -356,7 +357,6 @@ export function createHttpServer(deps: HttpServerDeps) {
|
||||
},
|
||||
stop: () => {
|
||||
closeSseClients()
|
||||
clientConnectionManager.shutdown()
|
||||
return app.close()
|
||||
},
|
||||
}
|
||||
@@ -765,52 +765,6 @@ function normalizeInstanceSuffix(pathSuffix: string | undefined) {
|
||||
return trimmed.length === 0 ? "/" : `/${trimmed}`
|
||||
}
|
||||
|
||||
type WorktreeCacheEntry = {
|
||||
expiresAt: number
|
||||
repoRoot: string
|
||||
worktrees: Array<{ slug: string; directory: string }>
|
||||
}
|
||||
|
||||
const WORKTREE_CACHE_TTL_MS = 2000
|
||||
const worktreeCache = new Map<string, WorktreeCacheEntry>()
|
||||
|
||||
async function getCachedWorktrees(params: { workspaceId: string; workspacePath: string; logger: Logger }) {
|
||||
const cached = worktreeCache.get(params.workspaceId)
|
||||
const now = Date.now()
|
||||
if (cached && cached.expiresAt > now) {
|
||||
return cached
|
||||
}
|
||||
|
||||
const { repoRoot } = await resolveRepoRoot(params.workspacePath, params.logger)
|
||||
const worktrees = await listWorktrees({ repoRoot, workspaceFolder: params.workspacePath, logger: params.logger })
|
||||
const entry: WorktreeCacheEntry = {
|
||||
expiresAt: now + WORKTREE_CACHE_TTL_MS,
|
||||
repoRoot,
|
||||
worktrees: worktrees.map((wt) => ({ slug: wt.slug, directory: wt.directory })),
|
||||
}
|
||||
worktreeCache.set(params.workspaceId, entry)
|
||||
return entry
|
||||
}
|
||||
|
||||
async function resolveWorktreeDirectory(params: {
|
||||
workspaceId: string
|
||||
workspacePath: string
|
||||
worktreeSlug: string
|
||||
logger: Logger
|
||||
}): Promise<string | null> {
|
||||
const { worktreeSlug } = params
|
||||
const cached = await getCachedWorktrees({ workspaceId: params.workspaceId, workspacePath: params.workspacePath, logger: params.logger })
|
||||
const match = cached.worktrees.find((wt) => wt.slug === worktreeSlug)
|
||||
if (match) {
|
||||
return match.directory
|
||||
}
|
||||
|
||||
// If the slug is new (e.g., created moments ago), refresh once.
|
||||
worktreeCache.delete(params.workspaceId)
|
||||
const refreshed = await getCachedWorktrees({ workspaceId: params.workspaceId, workspacePath: params.workspacePath, logger: params.logger })
|
||||
return refreshed.worktrees.find((wt) => wt.slug === worktreeSlug)?.directory ?? null
|
||||
}
|
||||
|
||||
function setupStaticUi(app: FastifyInstance, uiDir: string, authManager: AuthManager) {
|
||||
if (!uiDir) {
|
||||
app.log.warn("UI static directory not provided; API endpoints only")
|
||||
|
||||
533
packages/server/src/server/remote-proxy.ts
Normal file
533
packages/server/src/server/remote-proxy.ts
Normal file
@@ -0,0 +1,533 @@
|
||||
import Fastify, { type FastifyInstance, type FastifyReply, type FastifyRequest } from "fastify"
|
||||
import { randomBytes, randomUUID } from "crypto"
|
||||
import { Readable } from "stream"
|
||||
import { Agent, fetch } from "undici"
|
||||
import type { AuthManager } from "../auth/manager"
|
||||
import type { Logger } from "../logger"
|
||||
|
||||
const LOOPBACK_HOST = "127.0.0.1"
|
||||
const BOOTSTRAP_PAGE_PATH = "/__codenomad/auth/token"
|
||||
const BOOTSTRAP_EXCHANGE_PATH = "/__codenomad/api/auth/token"
|
||||
const SESSION_IDLE_TTL_MS = 30 * 60_000
|
||||
|
||||
interface RemoteProxySession {
|
||||
id: string
|
||||
targetBaseUrl: URL
|
||||
skipTlsVerify: boolean
|
||||
localBaseUrl: URL
|
||||
entryUrl: URL
|
||||
bootstrapUrl: string
|
||||
activated: boolean
|
||||
cookiePrefix: string
|
||||
app: FastifyInstance
|
||||
dispatcher?: Agent
|
||||
createdAt: number
|
||||
lastAccessAt: number
|
||||
}
|
||||
|
||||
export interface RemoteProxySessionManagerOptions {
|
||||
authManager: AuthManager
|
||||
logger: Logger
|
||||
httpsOptions?: { key: string | Buffer; cert: string | Buffer; ca?: string | Buffer }
|
||||
}
|
||||
|
||||
export class RemoteProxySessionManager {
|
||||
private readonly sessions = new Map<string, RemoteProxySession>()
|
||||
private readonly cleanupTimer: NodeJS.Timeout
|
||||
|
||||
constructor(private readonly options: RemoteProxySessionManagerOptions) {
|
||||
this.cleanupTimer = setInterval(() => {
|
||||
void this.cleanupExpiredSessions()
|
||||
}, 60_000)
|
||||
this.cleanupTimer.unref()
|
||||
}
|
||||
|
||||
async createSession(baseUrl: string, skipTlsVerify: boolean): Promise<string> {
|
||||
if (!this.options.httpsOptions) {
|
||||
throw new Error("Local HTTPS is required for remote proxy sessions")
|
||||
}
|
||||
|
||||
const targetBaseUrl = normalizeBaseUrl(baseUrl)
|
||||
const token = this.options.authManager.issueBootstrapToken()
|
||||
if (!token) {
|
||||
throw new Error("Bootstrap token generation is unavailable")
|
||||
}
|
||||
|
||||
const sessionId = randomUUID()
|
||||
const dispatcher = skipTlsVerify ? new Agent({ connect: { rejectUnauthorized: false } }) : undefined
|
||||
const app = Fastify({ logger: false, https: this.options.httpsOptions })
|
||||
let session: RemoteProxySession | null = null
|
||||
|
||||
app.removeAllContentTypeParsers()
|
||||
app.addContentTypeParser("*", (req, body, done) => done(null, body))
|
||||
|
||||
app.get(BOOTSTRAP_PAGE_PATH, async (request, reply) => {
|
||||
if (!this.options.authManager.isLoopbackRequest(request)) {
|
||||
reply.code(404).send({ error: "Not found" })
|
||||
return
|
||||
}
|
||||
|
||||
reply.header("Cache-Control", "no-store")
|
||||
reply.header("Pragma", "no-cache")
|
||||
reply.header("Expires", "0")
|
||||
reply.type("text/html").send(buildBootstrapPageHtml())
|
||||
})
|
||||
|
||||
app.post(BOOTSTRAP_EXCHANGE_PATH, async (request, reply) => {
|
||||
if (!this.options.authManager.isLoopbackRequest(request)) {
|
||||
reply.code(404).send({ error: "Not found" })
|
||||
return
|
||||
}
|
||||
|
||||
const body = parseTokenBody(request.body)
|
||||
if (!this.options.authManager.consumeBootstrapToken(body.token)) {
|
||||
reply.code(401).send({ error: "Invalid token" })
|
||||
return
|
||||
}
|
||||
|
||||
if (!session) {
|
||||
reply.code(503).send({ error: "Remote proxy session is unavailable" })
|
||||
return
|
||||
}
|
||||
|
||||
session.activated = true
|
||||
session.lastAccessAt = Date.now()
|
||||
reply.send({ ok: true })
|
||||
})
|
||||
|
||||
app.all("/*", async (request, reply) => {
|
||||
if (!session) {
|
||||
reply.code(503).send({ error: "Remote proxy session is unavailable" })
|
||||
return
|
||||
}
|
||||
|
||||
if (!session.activated) {
|
||||
reply.code(403).send({ error: "Remote proxy session is not activated" })
|
||||
return
|
||||
}
|
||||
|
||||
session.lastAccessAt = Date.now()
|
||||
await proxyRequest({ request, reply, session, logger: this.options.logger })
|
||||
})
|
||||
|
||||
app.setNotFoundHandler(async (request, reply) => {
|
||||
if (!session) {
|
||||
reply.code(503).send({ error: "Remote proxy session is unavailable" })
|
||||
return
|
||||
}
|
||||
|
||||
if (!session.activated) {
|
||||
reply.code(403).send({ error: "Remote proxy session is not activated" })
|
||||
return
|
||||
}
|
||||
|
||||
session.lastAccessAt = Date.now()
|
||||
await proxyRequest({ request, reply, session, logger: this.options.logger })
|
||||
})
|
||||
|
||||
const addressInfo = await app.listen({ host: LOOPBACK_HOST, port: 0 })
|
||||
const address = new URL(addressInfo)
|
||||
const localBaseUrl = new URL(`https://${LOOPBACK_HOST}:${address.port}`)
|
||||
const entryUrl = new URL(targetBaseUrl.pathname || "/", localBaseUrl)
|
||||
const returnTo = buildReturnToTarget(entryUrl)
|
||||
|
||||
session = {
|
||||
id: sessionId,
|
||||
targetBaseUrl,
|
||||
skipTlsVerify,
|
||||
localBaseUrl,
|
||||
entryUrl,
|
||||
bootstrapUrl: `${localBaseUrl.origin}${BOOTSTRAP_PAGE_PATH}?returnTo=${encodeURIComponent(returnTo)}#${encodeURIComponent(token)}`,
|
||||
activated: false,
|
||||
cookiePrefix: `cnrp_${randomBytes(6).toString("hex")}_`,
|
||||
app,
|
||||
dispatcher,
|
||||
createdAt: Date.now(),
|
||||
lastAccessAt: Date.now(),
|
||||
}
|
||||
|
||||
this.sessions.set(sessionId, session)
|
||||
this.options.logger.info(
|
||||
{ sessionId, targetBaseUrl: targetBaseUrl.toString(), localBaseUrl: localBaseUrl.toString() },
|
||||
"Created remote proxy session",
|
||||
)
|
||||
|
||||
return session.bootstrapUrl
|
||||
}
|
||||
|
||||
private async cleanupExpiredSessions() {
|
||||
const now = Date.now()
|
||||
for (const session of Array.from(this.sessions.values())) {
|
||||
if (now - session.lastAccessAt <= SESSION_IDLE_TTL_MS) {
|
||||
continue
|
||||
}
|
||||
await this.disposeSession(session.id)
|
||||
}
|
||||
}
|
||||
|
||||
private async disposeSession(sessionId: string) {
|
||||
const session = this.sessions.get(sessionId)
|
||||
if (!session) {
|
||||
return
|
||||
}
|
||||
|
||||
this.sessions.delete(sessionId)
|
||||
session.dispatcher?.close().catch(() => {})
|
||||
await session.app.close().catch(() => {})
|
||||
this.options.logger.info({ sessionId }, "Disposed remote proxy session")
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeBaseUrl(input: string): URL {
|
||||
const parsed = new URL(input.trim())
|
||||
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
|
||||
throw new Error("Server URL must use http:// or https://")
|
||||
}
|
||||
|
||||
parsed.hash = ""
|
||||
parsed.search = ""
|
||||
parsed.pathname = parsed.pathname === "/" ? "/" : parsed.pathname.replace(/\/+$/, "") || "/"
|
||||
return parsed
|
||||
}
|
||||
|
||||
function buildReturnToTarget(entryUrl: URL): string {
|
||||
const query = entryUrl.search ? entryUrl.search : ""
|
||||
return `${entryUrl.pathname || "/"}${query}`
|
||||
}
|
||||
|
||||
function buildBootstrapPageHtml(): string {
|
||||
return `<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>CodeNomad</title>
|
||||
<style>
|
||||
body { font-family: ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, Helvetica, Arial; background: #0b0b0f; color: #fff; display: flex; align-items: center; justify-content: center; height: 100vh; margin: 0; }
|
||||
.card { width: 420px; max-width: calc(100vw - 32px); background: #14141c; border: 1px solid rgba(255,255,255,0.08); border-radius: 14px; padding: 24px; }
|
||||
h1 { font-size: 18px; margin: 0 0 12px; }
|
||||
p { margin: 0; color: rgba(255,255,255,0.7); font-size: 13px; line-height: 1.4; }
|
||||
.error { margin-top: 12px; color: #ff6b6b; font-size: 13px; display: none; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="card">
|
||||
<h1>Connecting...</h1>
|
||||
<p>Finalizing local authentication.</p>
|
||||
<div id="error" class="error"></div>
|
||||
</div>
|
||||
<script>
|
||||
const token = decodeURIComponent((location.hash || "").replace(/^#/, "").trim())
|
||||
const params = new URLSearchParams(location.search)
|
||||
const returnTo = sanitizeReturnTo(params.get("returnTo"))
|
||||
const errorEl = document.getElementById("error")
|
||||
|
||||
function sanitizeReturnTo(value) {
|
||||
if (!value || typeof value !== "string") return "/"
|
||||
if (!value.startsWith("/")) return "/"
|
||||
if (value.startsWith("//")) return "/"
|
||||
return value
|
||||
}
|
||||
|
||||
function showError(message) {
|
||||
errorEl.textContent = message
|
||||
errorEl.style.display = "block"
|
||||
}
|
||||
|
||||
async function run() {
|
||||
if (!token) {
|
||||
showError("Missing bootstrap token.")
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await fetch("${BOOTSTRAP_EXCHANGE_PATH}", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ token }),
|
||||
credentials: "include",
|
||||
})
|
||||
|
||||
if (!res.ok) {
|
||||
let message = ""
|
||||
try {
|
||||
const json = await res.json()
|
||||
message = json && json.error ? String(json.error) : ""
|
||||
} catch {
|
||||
message = ""
|
||||
}
|
||||
showError(message || "Token exchange failed (" + res.status + ")")
|
||||
return
|
||||
}
|
||||
|
||||
window.location.replace(returnTo)
|
||||
} catch (error) {
|
||||
showError(error && error.message ? error.message : String(error))
|
||||
}
|
||||
}
|
||||
|
||||
run()
|
||||
</script>
|
||||
</body>
|
||||
</html>`
|
||||
}
|
||||
|
||||
function parseTokenBody(body: unknown): { token: string } {
|
||||
const value = normalizeJsonBody(body) as { token?: unknown } | null | undefined
|
||||
const token = typeof value?.token === "string" ? value.token.trim() : ""
|
||||
if (!token) {
|
||||
throw new Error("Missing bootstrap token")
|
||||
}
|
||||
return { token }
|
||||
}
|
||||
|
||||
function normalizeJsonBody(body: unknown): unknown {
|
||||
if (Buffer.isBuffer(body)) {
|
||||
return JSON.parse(body.toString("utf-8"))
|
||||
}
|
||||
if (typeof body === "string") {
|
||||
return JSON.parse(body)
|
||||
}
|
||||
return body
|
||||
}
|
||||
|
||||
function toRequestBody(body: unknown): any {
|
||||
if (body == null) {
|
||||
return undefined
|
||||
}
|
||||
if (Buffer.isBuffer(body) || typeof body === "string" || body instanceof Uint8Array) {
|
||||
return body
|
||||
}
|
||||
return JSON.stringify(body)
|
||||
}
|
||||
|
||||
async function proxyRequest(args: {
|
||||
request: FastifyRequest
|
||||
reply: FastifyReply
|
||||
session: RemoteProxySession
|
||||
logger: Logger
|
||||
}) {
|
||||
const { request, reply, session, logger } = args
|
||||
const upstreamUrl = buildUpstreamUrl(session.targetBaseUrl, request.raw.url ?? request.url)
|
||||
const headers = filterRequestHeaders(request.headers, session)
|
||||
|
||||
const init: any = {
|
||||
method: request.method,
|
||||
headers,
|
||||
dispatcher: session.dispatcher,
|
||||
redirect: "manual",
|
||||
}
|
||||
|
||||
if (request.method !== "GET" && request.method !== "HEAD") {
|
||||
const body = toRequestBody(request.body)
|
||||
if (body !== undefined) {
|
||||
init.body = body
|
||||
init.duplex = "half"
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(upstreamUrl, init as any)
|
||||
reply.code(response.status)
|
||||
applyResponseHeaders(reply, response, session)
|
||||
|
||||
if (!response.body || request.method === "HEAD") {
|
||||
reply.send()
|
||||
return
|
||||
}
|
||||
|
||||
reply.send(Readable.fromWeb(response.body as any))
|
||||
} catch (error) {
|
||||
logger.error({ err: error, upstreamUrl }, "Failed to proxy remote session request")
|
||||
if (!reply.sent) {
|
||||
reply.code(502).send({ error: "Remote proxy request failed" })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function buildUpstreamUrl(baseUrl: URL, rawUrl: string): string {
|
||||
const parsed = new URL(rawUrl, "https://localhost")
|
||||
const url = new URL(baseUrl.toString())
|
||||
url.pathname = rewriteRequestPath(baseUrl, parsed.pathname)
|
||||
url.search = stripInternalQuery(parsed.search)
|
||||
url.hash = ""
|
||||
return url.toString()
|
||||
}
|
||||
|
||||
function rewriteRequestPath(baseUrl: URL, requestPath: string): string {
|
||||
const basePath = normalizedBasePath(baseUrl)
|
||||
if (basePath === "/") {
|
||||
return requestPath
|
||||
}
|
||||
|
||||
if (requestPath === "/") {
|
||||
return basePath
|
||||
}
|
||||
|
||||
if (pathHasBasePrefix(basePath, requestPath)) {
|
||||
return requestPath
|
||||
}
|
||||
|
||||
return `${basePath}${requestPath}`
|
||||
}
|
||||
|
||||
function normalizedBasePath(baseUrl: URL): string {
|
||||
return baseUrl.pathname || "/"
|
||||
}
|
||||
|
||||
function pathHasBasePrefix(basePath: string, requestPath: string): boolean {
|
||||
return requestPath === basePath || requestPath.startsWith(`${basePath}/`)
|
||||
}
|
||||
|
||||
function stripInternalQuery(search: string): string {
|
||||
if (!search || search === "?") {
|
||||
return ""
|
||||
}
|
||||
return search
|
||||
}
|
||||
|
||||
function filterRequestHeaders(
|
||||
headers: FastifyRequest["headers"],
|
||||
session: RemoteProxySession,
|
||||
): Record<string, string> {
|
||||
const next: Record<string, string> = {}
|
||||
for (const [key, value] of Object.entries(headers ?? {})) {
|
||||
if (!value) continue
|
||||
const lower = key.toLowerCase()
|
||||
if (isHopByHopHeader(lower) || lower === "host" || lower === "content-length") {
|
||||
continue
|
||||
}
|
||||
if (lower === "origin") {
|
||||
next[key] = session.targetBaseUrl.origin
|
||||
continue
|
||||
}
|
||||
if (lower === "referer") {
|
||||
const rewritten = rewriteRefererHeader(Array.isArray(value) ? value[0] : value, session.targetBaseUrl)
|
||||
if (rewritten) {
|
||||
next[key] = rewritten
|
||||
}
|
||||
continue
|
||||
}
|
||||
if (lower === "cookie") {
|
||||
const rewritten = rewriteRequestCookieHeader(Array.isArray(value) ? value.join("; ") : value, session.cookiePrefix)
|
||||
if (rewritten) {
|
||||
next[key] = rewritten
|
||||
}
|
||||
continue
|
||||
}
|
||||
next[key] = Array.isArray(value) ? value.join(",") : value
|
||||
}
|
||||
|
||||
next.host = session.targetBaseUrl.port ? `${session.targetBaseUrl.hostname}:${session.targetBaseUrl.port}` : session.targetBaseUrl.hostname
|
||||
if (!next.origin) {
|
||||
next.origin = session.targetBaseUrl.origin
|
||||
}
|
||||
return next
|
||||
}
|
||||
|
||||
function rewriteRefererHeader(referer: string | undefined, targetBaseUrl: URL): string | null {
|
||||
if (!referer) {
|
||||
return null
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = new URL(referer)
|
||||
const rewritten = new URL(targetBaseUrl.toString())
|
||||
rewritten.pathname = rewriteRequestPath(targetBaseUrl, parsed.pathname)
|
||||
rewritten.search = parsed.search
|
||||
rewritten.hash = parsed.hash
|
||||
return rewritten.toString()
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function applyResponseHeaders(reply: FastifyReply, response: any, session: RemoteProxySession) {
|
||||
const setCookie = (response.headers as any).getSetCookie?.() as string[] | undefined
|
||||
if (Array.isArray(setCookie)) {
|
||||
for (const cookie of setCookie) {
|
||||
reply.header("set-cookie", rewriteSetCookie(cookie, session.cookiePrefix))
|
||||
}
|
||||
}
|
||||
|
||||
response.headers.forEach((value: string, key: string) => {
|
||||
const lower = key.toLowerCase()
|
||||
if (isHopByHopHeader(lower) || lower === "set-cookie") {
|
||||
return
|
||||
}
|
||||
|
||||
if (lower === "location") {
|
||||
reply.header(key, rewriteLocation(value, session.targetBaseUrl, session.localBaseUrl))
|
||||
return
|
||||
}
|
||||
|
||||
reply.header(key, value)
|
||||
})
|
||||
}
|
||||
|
||||
function rewriteSetCookie(cookie: string, cookiePrefix: string): string {
|
||||
const parts = cookie.split(";").map((part) => part.trim())
|
||||
const first = parts.shift() ?? ""
|
||||
const separator = first.indexOf("=")
|
||||
if (separator <= 0) {
|
||||
return cookie
|
||||
}
|
||||
|
||||
const name = first.slice(0, separator).trim()
|
||||
const value = first.slice(separator + 1)
|
||||
const rewritten = [`${cookiePrefix}${name}=${value}`]
|
||||
for (const part of parts) {
|
||||
if (part.slice(0, 7).toLowerCase().startsWith("domain=")) {
|
||||
continue
|
||||
}
|
||||
rewritten.push(part)
|
||||
}
|
||||
return rewritten.join("; ")
|
||||
}
|
||||
|
||||
function rewriteRequestCookieHeader(cookieHeader: string, cookiePrefix: string): string {
|
||||
const next: string[] = []
|
||||
for (const rawPart of cookieHeader.split(";")) {
|
||||
const part = rawPart.trim()
|
||||
if (!part) continue
|
||||
const separator = part.indexOf("=")
|
||||
if (separator <= 0) continue
|
||||
const name = part.slice(0, separator).trim()
|
||||
const value = part.slice(separator + 1)
|
||||
if (!name.startsWith(cookiePrefix)) {
|
||||
continue
|
||||
}
|
||||
next.push(`${name.slice(cookiePrefix.length)}=${value}`)
|
||||
}
|
||||
return next.join("; ")
|
||||
}
|
||||
|
||||
function rewriteLocation(location: string, targetBaseUrl: URL, localBaseUrl: URL): string {
|
||||
try {
|
||||
const parsed = new URL(location, targetBaseUrl)
|
||||
if (parsed.origin !== targetBaseUrl.origin) {
|
||||
return location
|
||||
}
|
||||
|
||||
const rewritten = new URL(localBaseUrl.toString())
|
||||
rewritten.pathname = parsed.pathname
|
||||
rewritten.search = parsed.search
|
||||
rewritten.hash = parsed.hash
|
||||
return rewritten.toString()
|
||||
} catch {
|
||||
return location
|
||||
}
|
||||
}
|
||||
|
||||
function isHopByHopHeader(name: string): boolean {
|
||||
return new Set([
|
||||
"connection",
|
||||
"keep-alive",
|
||||
"proxy-authenticate",
|
||||
"proxy-authorization",
|
||||
"te",
|
||||
"trailer",
|
||||
"transfer-encoding",
|
||||
"upgrade",
|
||||
]).has(name)
|
||||
}
|
||||
@@ -66,11 +66,17 @@ export function registerPluginRoutes(app: FastifyInstance, deps: RouteDeps) {
|
||||
}
|
||||
|
||||
const payload = VoiceModeStateSchema.parse(request.body ?? {})
|
||||
deps.voiceModeManager.setEnabled(
|
||||
const applied = deps.voiceModeManager.setEnabled(
|
||||
request.params.id,
|
||||
{ clientId: payload.clientId, connectionId: payload.connectionId },
|
||||
payload.enabled,
|
||||
)
|
||||
|
||||
if (payload.enabled && !applied) {
|
||||
reply.code(409).send({ error: "Client connection not active for voice mode enable" })
|
||||
return
|
||||
}
|
||||
|
||||
return { enabled: payload.enabled }
|
||||
})
|
||||
|
||||
|
||||
29
packages/server/src/server/routes/remote-proxy.ts
Normal file
29
packages/server/src/server/routes/remote-proxy.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import type { FastifyInstance } from "fastify"
|
||||
import { z } from "zod"
|
||||
import type { RemoteProxySessionCreateResponse } from "../../api-types"
|
||||
import type { Logger } from "../../logger"
|
||||
import type { RemoteProxySessionManager } from "../remote-proxy"
|
||||
|
||||
interface RouteDeps {
|
||||
logger: Logger
|
||||
sessionManager: RemoteProxySessionManager
|
||||
}
|
||||
|
||||
const CreateSessionSchema = z.object({
|
||||
baseUrl: z.string().min(1),
|
||||
skipTlsVerify: z.boolean().optional(),
|
||||
})
|
||||
|
||||
export function registerRemoteProxyRoutes(app: FastifyInstance, deps: RouteDeps) {
|
||||
app.post("/api/remote-proxy/sessions", async (request, reply): Promise<RemoteProxySessionCreateResponse | { error: string }> => {
|
||||
try {
|
||||
const body = CreateSessionSchema.parse(request.body ?? {})
|
||||
const windowUrl = await deps.sessionManager.createSession(body.baseUrl, Boolean(body.skipTlsVerify))
|
||||
return { windowUrl }
|
||||
} catch (error) {
|
||||
deps.logger.warn({ err: error }, "Failed to create remote proxy session")
|
||||
reply.code(400)
|
||||
return { error: error instanceof Error ? error.message : "Failed to create remote proxy session" }
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -1,6 +1,10 @@
|
||||
import { FastifyInstance, FastifyReply } from "fastify"
|
||||
import { z } from "zod"
|
||||
import { WorkspaceManager } from "../../workspaces/manager"
|
||||
import { getWorktreeGitDiff, getWorktreeGitStatus } from "../../workspaces/git-status"
|
||||
import { commitWorktreeChanges, isGitMutationError, stageWorktreePaths, unstageWorktreePaths } from "../../workspaces/git-mutations"
|
||||
import { isGitAvailable, resolveRepoRoot } from "../../workspaces/git-worktrees"
|
||||
import { resolveWorktreeDirectory } from "../../workspaces/worktree-directory"
|
||||
|
||||
interface RouteDeps {
|
||||
workspaceManager: WorkspaceManager
|
||||
@@ -23,6 +27,20 @@ const WorkspaceFileContentBodySchema = z.object({
|
||||
contents: z.string(),
|
||||
})
|
||||
|
||||
const WorktreeGitDiffQuerySchema = z.object({
|
||||
path: z.string().trim().min(1, "Path is required"),
|
||||
originalPath: z.string().trim().optional(),
|
||||
scope: z.enum(["staged", "unstaged"]),
|
||||
})
|
||||
|
||||
const WorktreeGitPathsBodySchema = z.object({
|
||||
paths: z.array(z.string().trim().min(1, "Path is required")).min(1, "At least one path is required"),
|
||||
})
|
||||
|
||||
const WorktreeGitCommitBodySchema = z.object({
|
||||
message: z.string().trim().min(1, "Commit message is required"),
|
||||
})
|
||||
|
||||
const WorkspaceFileSearchQuerySchema = z.object({
|
||||
q: z.string().trim().min(1, "Query is required"),
|
||||
limit: z.coerce.number().int().positive().max(200).optional(),
|
||||
@@ -118,10 +136,138 @@ export function registerWorkspaceRoutes(app: FastifyInstance, deps: RouteDeps) {
|
||||
return handleWorkspaceError(error, reply)
|
||||
}
|
||||
})
|
||||
|
||||
app.get<{
|
||||
Params: { id: string; slug: string }
|
||||
}>("/api/workspaces/:id/worktrees/:slug/git-status", async (request, reply) => {
|
||||
try {
|
||||
const directory = await resolveGitWorktreeDirectory(deps.workspaceManager, request.params.id, request.params.slug, request.log, reply)
|
||||
if (!directory) return
|
||||
|
||||
return await getWorktreeGitStatus({ workspaceFolder: directory, logger: request.log })
|
||||
} catch (error) {
|
||||
return handleWorkspaceError(error, reply)
|
||||
}
|
||||
})
|
||||
|
||||
app.get<{
|
||||
Params: { id: string; slug: string }
|
||||
Querystring: { path: string; originalPath?: string; scope: "staged" | "unstaged" }
|
||||
}>("/api/workspaces/:id/worktrees/:slug/git-diff", async (request, reply) => {
|
||||
try {
|
||||
const query = WorktreeGitDiffQuerySchema.parse(request.query ?? {})
|
||||
const directory = await resolveGitWorktreeDirectory(deps.workspaceManager, request.params.id, request.params.slug, request.log, reply)
|
||||
if (!directory) return
|
||||
|
||||
return await getWorktreeGitDiff({
|
||||
workspaceFolder: directory,
|
||||
path: query.path,
|
||||
originalPath: query.originalPath,
|
||||
scope: query.scope,
|
||||
})
|
||||
} catch (error) {
|
||||
return handleWorkspaceError(error, reply)
|
||||
}
|
||||
})
|
||||
|
||||
app.post<{
|
||||
Params: { id: string; slug: string }
|
||||
Body: { paths: string[] }
|
||||
}>("/api/workspaces/:id/worktrees/:slug/git-stage", async (request, reply) => {
|
||||
try {
|
||||
const body = WorktreeGitPathsBodySchema.parse(request.body ?? {})
|
||||
const directory = await resolveGitWorktreeDirectory(deps.workspaceManager, request.params.id, request.params.slug, request.log, reply)
|
||||
if (!directory) return
|
||||
|
||||
await stageWorktreePaths({ workspaceFolder: directory, paths: body.paths })
|
||||
return { ok: true as const }
|
||||
} catch (error) {
|
||||
return handleWorkspaceError(error, reply)
|
||||
}
|
||||
})
|
||||
|
||||
app.post<{
|
||||
Params: { id: string; slug: string }
|
||||
Body: { paths: string[] }
|
||||
}>("/api/workspaces/:id/worktrees/:slug/git-unstage", async (request, reply) => {
|
||||
try {
|
||||
const body = WorktreeGitPathsBodySchema.parse(request.body ?? {})
|
||||
const directory = await resolveGitWorktreeDirectory(deps.workspaceManager, request.params.id, request.params.slug, request.log, reply)
|
||||
if (!directory) return
|
||||
|
||||
await unstageWorktreePaths({ workspaceFolder: directory, paths: body.paths })
|
||||
return { ok: true as const }
|
||||
} catch (error) {
|
||||
return handleWorkspaceError(error, reply)
|
||||
}
|
||||
})
|
||||
|
||||
app.post<{
|
||||
Params: { id: string; slug: string }
|
||||
Body: { message: string }
|
||||
}>("/api/workspaces/:id/worktrees/:slug/git-commit", async (request, reply) => {
|
||||
try {
|
||||
const body = WorktreeGitCommitBodySchema.parse(request.body ?? {})
|
||||
const directory = await resolveGitWorktreeDirectory(deps.workspaceManager, request.params.id, request.params.slug, request.log, reply)
|
||||
if (!directory) return
|
||||
|
||||
const result = await commitWorktreeChanges({ workspaceFolder: directory, message: body.message })
|
||||
return { ok: true as const, ...result }
|
||||
} catch (error) {
|
||||
return handleWorkspaceError(error, reply)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
async function resolveGitWorktreeDirectory(
|
||||
workspaceManager: WorkspaceManager,
|
||||
workspaceId: string,
|
||||
worktreeSlug: string,
|
||||
logger: { debug?: (obj: any, msg?: string) => void; warn?: (obj: any, msg?: string) => void },
|
||||
reply: FastifyReply,
|
||||
): Promise<string | null> {
|
||||
const workspace = workspaceManager.get(workspaceId)
|
||||
if (!workspace) {
|
||||
reply.code(404)
|
||||
reply.send({ error: "Workspace not found" })
|
||||
return null
|
||||
}
|
||||
|
||||
const gitAvailable = await isGitAvailable(workspace.path)
|
||||
if (!gitAvailable) {
|
||||
reply.code(503)
|
||||
reply.send({ error: "Git is not installed or not available in PATH" })
|
||||
return null
|
||||
}
|
||||
|
||||
const { isGitRepo } = await resolveRepoRoot(workspace.path, logger)
|
||||
if (!isGitRepo) {
|
||||
reply.code(400)
|
||||
reply.send({ error: "Workspace is not a Git repository" })
|
||||
return null
|
||||
}
|
||||
|
||||
const directory = await resolveWorktreeDirectory({
|
||||
workspaceId: workspace.id,
|
||||
workspacePath: workspace.path,
|
||||
worktreeSlug,
|
||||
logger,
|
||||
})
|
||||
if (!directory) {
|
||||
reply.code(404)
|
||||
reply.send({ error: "Worktree not found" })
|
||||
return null
|
||||
}
|
||||
|
||||
return directory
|
||||
}
|
||||
|
||||
|
||||
function handleWorkspaceError(error: unknown, reply: FastifyReply) {
|
||||
if (isGitMutationError(error)) {
|
||||
reply.code(error.statusCode)
|
||||
return { error: error.message }
|
||||
}
|
||||
if (error instanceof Error && error.message === "Workspace not found") {
|
||||
reply.code(404)
|
||||
return { error: "Workspace not found" }
|
||||
|
||||
121
packages/server/src/workspaces/git-mutations.ts
Normal file
121
packages/server/src/workspaces/git-mutations.ts
Normal file
@@ -0,0 +1,121 @@
|
||||
import { spawn } from "child_process"
|
||||
import path from "path"
|
||||
|
||||
type GitResult = { ok: true; stdout: string } | { ok: false; error: Error; stdout?: string; stderr?: string }
|
||||
|
||||
class GitMutationError extends Error {
|
||||
statusCode: number
|
||||
|
||||
constructor(message: string, statusCode = 400) {
|
||||
super(message)
|
||||
this.name = "GitMutationError"
|
||||
this.statusCode = statusCode
|
||||
}
|
||||
}
|
||||
|
||||
function runGit(args: string[], cwd: string): Promise<GitResult> {
|
||||
return new Promise((resolve) => {
|
||||
const child = spawn("git", args, { cwd, stdio: ["ignore", "pipe", "pipe"] })
|
||||
let stdout = ""
|
||||
let stderr = ""
|
||||
|
||||
child.stdout?.on("data", (chunk) => {
|
||||
stdout += chunk.toString()
|
||||
})
|
||||
child.stderr?.on("data", (chunk) => {
|
||||
stderr += chunk.toString()
|
||||
})
|
||||
child.once("error", (error) => {
|
||||
resolve({ ok: false, error, stdout, stderr })
|
||||
})
|
||||
child.once("close", (code) => {
|
||||
if (code === 0) {
|
||||
resolve({ ok: true, stdout })
|
||||
} else {
|
||||
const error = new Error(stderr.trim() || `git ${args.join(" ")} failed with code ${code}`)
|
||||
resolve({ ok: false, error, stdout, stderr })
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
export function normalizeGitWorktreeRelativePath(input: string): string {
|
||||
const normalized = input.trim().replace(/\\+/g, "/").replace(/^\.\//, "")
|
||||
if (!normalized) {
|
||||
throw new GitMutationError("Path is required", 400)
|
||||
}
|
||||
if (path.posix.isAbsolute(normalized) || path.win32.isAbsolute(normalized)) {
|
||||
throw new GitMutationError(`Absolute paths are not allowed: ${input}`, 400)
|
||||
}
|
||||
if (normalized === "." || normalized === "..") {
|
||||
throw new GitMutationError(`Invalid path: ${input}`, 400)
|
||||
}
|
||||
if (normalized.startsWith("../") || normalized.includes("/../") || normalized.endsWith("/..")) {
|
||||
throw new GitMutationError(`Path traversal is not allowed: ${input}`, 400)
|
||||
}
|
||||
return normalized
|
||||
}
|
||||
|
||||
function normalizeGitMutationPaths(paths: string[]): string[] {
|
||||
const deduped = new Set<string>()
|
||||
for (const rawPath of paths) {
|
||||
deduped.add(normalizeGitWorktreeRelativePath(rawPath))
|
||||
}
|
||||
const normalized = Array.from(deduped)
|
||||
if (normalized.length === 0) {
|
||||
throw new GitMutationError("At least one path is required", 400)
|
||||
}
|
||||
return normalized
|
||||
}
|
||||
|
||||
async function ensureGitCommandSucceeded(resultPromise: Promise<GitResult>, fallbackMessage: string): Promise<string> {
|
||||
const result = await resultPromise
|
||||
if (!result.ok) {
|
||||
const message = result.stderr?.trim() || result.error.message || fallbackMessage
|
||||
throw new GitMutationError(message, 409)
|
||||
}
|
||||
return result.stdout
|
||||
}
|
||||
|
||||
export function isGitMutationError(error: unknown): error is GitMutationError {
|
||||
return error instanceof GitMutationError
|
||||
}
|
||||
|
||||
export async function stageWorktreePaths(params: { workspaceFolder: string; paths: string[] }): Promise<void> {
|
||||
const paths = normalizeGitMutationPaths(params.paths)
|
||||
await ensureGitCommandSucceeded(runGit(["add", "--", ...paths], params.workspaceFolder), "Failed to stage files")
|
||||
}
|
||||
|
||||
export async function unstageWorktreePaths(params: { workspaceFolder: string; paths: string[] }): Promise<void> {
|
||||
const paths = normalizeGitMutationPaths(params.paths)
|
||||
const headResult = await runGit(["rev-parse", "--verify", "HEAD"], params.workspaceFolder)
|
||||
if (headResult.ok) {
|
||||
await ensureGitCommandSucceeded(
|
||||
runGit(["restore", "--staged", "--", ...paths], params.workspaceFolder),
|
||||
"Failed to unstage files",
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
await ensureGitCommandSucceeded(
|
||||
runGit(["rm", "--cached", "--quiet", "--", ...paths], params.workspaceFolder),
|
||||
"Failed to unstage files",
|
||||
)
|
||||
}
|
||||
|
||||
export async function commitWorktreeChanges(params: { workspaceFolder: string; message: string }): Promise<{ commitSha?: string }> {
|
||||
const message = params.message.trim()
|
||||
if (!message) {
|
||||
throw new GitMutationError("Commit message is required", 400)
|
||||
}
|
||||
|
||||
await ensureGitCommandSucceeded(runGit(["commit", "-m", message], params.workspaceFolder), "Failed to create commit")
|
||||
|
||||
const shaResult = await runGit(["rev-parse", "HEAD"], params.workspaceFolder)
|
||||
if (!shaResult.ok) {
|
||||
return {}
|
||||
}
|
||||
|
||||
const commitSha = shaResult.stdout.trim()
|
||||
return commitSha ? { commitSha } : {}
|
||||
}
|
||||
385
packages/server/src/workspaces/git-status.ts
Normal file
385
packages/server/src/workspaces/git-status.ts
Normal file
@@ -0,0 +1,385 @@
|
||||
import { spawn } from "child_process"
|
||||
import { readFile } from "fs/promises"
|
||||
import path from "path"
|
||||
|
||||
import type { GitChangeKind, WorktreeGitDiffResponse, WorktreeGitDiffScope, WorktreeGitStatusEntry } from "../api-types"
|
||||
import type { LogLike } from "./git-worktrees"
|
||||
import { normalizeGitWorktreeRelativePath } from "./git-mutations"
|
||||
|
||||
type GitResult = { ok: true; stdout: string } | { ok: false; error: Error; stdout?: string; stderr?: string }
|
||||
type GitSuccessResult = Extract<GitResult, { ok: true }>
|
||||
|
||||
async function readFileAsDiffText(filePath: string): Promise<string> {
|
||||
return readFile(filePath, "utf-8")
|
||||
}
|
||||
|
||||
async function readGitBlobAsDiffText(resultPromise: Promise<GitResult>, missingOk = false): Promise<string> {
|
||||
const result = await resultPromise
|
||||
if (!result.ok) {
|
||||
return decodeGitShowResult(result, missingOk)
|
||||
}
|
||||
return result.stdout
|
||||
}
|
||||
|
||||
function runGit(args: string[], cwd: string, acceptedExitCodes: number[] = [0]): Promise<GitResult> {
|
||||
return new Promise((resolve) => {
|
||||
const child = spawn("git", args, { cwd, stdio: ["ignore", "pipe", "pipe"] })
|
||||
let stdout = ""
|
||||
let stderr = ""
|
||||
|
||||
child.stdout?.on("data", (chunk) => {
|
||||
stdout += chunk.toString()
|
||||
})
|
||||
child.stderr?.on("data", (chunk) => {
|
||||
stderr += chunk.toString()
|
||||
})
|
||||
child.once("error", (error) => {
|
||||
resolve({ ok: false, error, stdout, stderr })
|
||||
})
|
||||
child.once("close", (code) => {
|
||||
if (acceptedExitCodes.includes(code ?? 0)) {
|
||||
resolve({ ok: true, stdout })
|
||||
} else {
|
||||
const error = new Error(stderr.trim() || `git ${args.join(" ")} failed with code ${code}`)
|
||||
resolve({ ok: false, error, stdout, stderr })
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
function ensureEntry(map: Map<string, WorktreeGitStatusEntry>, path: string): WorktreeGitStatusEntry {
|
||||
const existing = map.get(path)
|
||||
if (existing) return existing
|
||||
const next: WorktreeGitStatusEntry = {
|
||||
path,
|
||||
originalPath: null,
|
||||
stagedStatus: null,
|
||||
stagedAdditions: 0,
|
||||
stagedDeletions: 0,
|
||||
unstagedStatus: null,
|
||||
unstagedAdditions: 0,
|
||||
unstagedDeletions: 0,
|
||||
}
|
||||
map.set(path, next)
|
||||
return next
|
||||
}
|
||||
|
||||
function normalizeGitStatusPath(value: string): string {
|
||||
return value.trim().replace(/\\+/g, "/")
|
||||
}
|
||||
|
||||
function parseGitChangeKind(code: string): GitChangeKind | null {
|
||||
const normalized = code.trim().toUpperCase()
|
||||
if (!normalized) return null
|
||||
if (normalized === "A") return "added"
|
||||
if (normalized === "M") return "modified"
|
||||
if (normalized === "D") return "deleted"
|
||||
if (normalized.startsWith("R")) return "renamed"
|
||||
if (normalized.startsWith("C")) return "copied"
|
||||
if (normalized === "U") return "unmerged"
|
||||
return null
|
||||
}
|
||||
|
||||
function applyNameStatusOutput(
|
||||
map: Map<string, WorktreeGitStatusEntry>,
|
||||
output: string,
|
||||
target: "stagedStatus" | "unstagedStatus",
|
||||
) {
|
||||
const tokens = output.split("\0")
|
||||
let index = 0
|
||||
|
||||
while (index < tokens.length) {
|
||||
const record = tokens[index++] ?? ""
|
||||
if (!record) continue
|
||||
|
||||
const parts = record.split("\t")
|
||||
const statusCode = parseGitChangeKind(parts[0] ?? "")
|
||||
if (!statusCode) continue
|
||||
|
||||
const inlinePath = parts.slice(1).join("\t")
|
||||
const firstPath = inlinePath || tokens[index++] || ""
|
||||
const secondPath = statusCode === "renamed" || statusCode === "copied" ? tokens[index++] || "" : ""
|
||||
const path = statusCode === "renamed" || statusCode === "copied" ? secondPath || firstPath : firstPath
|
||||
const normalizedPath = normalizeGitStatusPath(path)
|
||||
if (!normalizedPath) continue
|
||||
const entry = ensureEntry(map, normalizedPath)
|
||||
entry[target] = statusCode
|
||||
if (statusCode === "renamed" || statusCode === "copied") {
|
||||
const originalPath = normalizeGitStatusPath(firstPath)
|
||||
entry.originalPath = originalPath || entry.originalPath || null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function applyUntrackedOutput(map: Map<string, WorktreeGitStatusEntry>, output: string) {
|
||||
for (const rawLine of output.split(/\r?\n/)) {
|
||||
const path = normalizeGitStatusPath(rawLine)
|
||||
if (!path) continue
|
||||
ensureEntry(map, path).unstagedStatus = "untracked"
|
||||
}
|
||||
}
|
||||
|
||||
function parseSingleNumstat(output: string): { additions: number; deletions: number; isBinary: boolean; found: boolean } {
|
||||
for (const rawLine of output.split(/\r?\n/)) {
|
||||
const line = rawLine.trim()
|
||||
if (!line) continue
|
||||
const parts = rawLine.split("\t")
|
||||
const isBinary = parts[0] === "-" || parts[1] === "-"
|
||||
return {
|
||||
additions: isBinary ? 0 : Number.parseInt(parts[0] ?? "0", 10) || 0,
|
||||
deletions: isBinary ? 0 : Number.parseInt(parts[1] ?? "0", 10) || 0,
|
||||
isBinary,
|
||||
found: true,
|
||||
}
|
||||
}
|
||||
|
||||
return { additions: 0, deletions: 0, isBinary: false, found: false }
|
||||
}
|
||||
|
||||
async function getUntrackedFileNumstat(workspaceFolder: string, relativePath: string): Promise<{ additions: number; deletions: number }> {
|
||||
const absolutePath = path.join(workspaceFolder, relativePath)
|
||||
const result = await runGit(["diff", "--numstat", "--no-index", "--", "/dev/null", absolutePath], workspaceFolder, [0, 1])
|
||||
if (!result.ok) {
|
||||
throw result.error
|
||||
}
|
||||
|
||||
const parsed = parseSingleNumstat(result.stdout)
|
||||
return { additions: parsed.additions, deletions: parsed.deletions }
|
||||
}
|
||||
|
||||
async function applyUntrackedFileStats(map: Map<string, WorktreeGitStatusEntry>, workspaceFolder: string) {
|
||||
const pending = Array.from(map.values())
|
||||
.filter((entry) => entry.unstagedStatus === "untracked")
|
||||
.map(async (entry) => {
|
||||
try {
|
||||
const stats = await getUntrackedFileNumstat(workspaceFolder, entry.path)
|
||||
entry.unstagedAdditions = stats.additions
|
||||
entry.unstagedDeletions = stats.deletions
|
||||
} catch {
|
||||
entry.unstagedAdditions = 0
|
||||
entry.unstagedDeletions = 0
|
||||
}
|
||||
})
|
||||
await Promise.all(pending)
|
||||
}
|
||||
|
||||
function applyNumstatOutput(
|
||||
map: Map<string, WorktreeGitStatusEntry>,
|
||||
output: string,
|
||||
target: "staged" | "unstaged",
|
||||
) {
|
||||
const tokens = output.split("\0")
|
||||
let index = 0
|
||||
|
||||
while (index < tokens.length) {
|
||||
const record = tokens[index++] ?? ""
|
||||
if (!record) continue
|
||||
|
||||
const parts = record.split("\t")
|
||||
if (parts.length < 3) continue
|
||||
|
||||
const additions = parts[0] === "-" ? 0 : Number.parseInt(parts[0] ?? "0", 10)
|
||||
const deletions = parts[1] === "-" ? 0 : Number.parseInt(parts[1] ?? "0", 10)
|
||||
const inlinePath = parts.slice(2).join("\t")
|
||||
const isRenameLike = inlinePath === ""
|
||||
const originalPath = isRenameLike ? normalizeGitStatusPath(tokens[index++] ?? "") : null
|
||||
const normalizedPath = normalizeGitStatusPath(isRenameLike ? tokens[index++] ?? "" : inlinePath)
|
||||
if (!normalizedPath) continue
|
||||
|
||||
const entry = ensureEntry(map, normalizedPath)
|
||||
if (originalPath) {
|
||||
entry.originalPath = originalPath
|
||||
}
|
||||
|
||||
if (target === "staged") {
|
||||
entry.stagedAdditions = Number.isFinite(additions) ? additions : 0
|
||||
entry.stagedDeletions = Number.isFinite(deletions) ? deletions : 0
|
||||
} else {
|
||||
entry.unstagedAdditions = Number.isFinite(additions) ? additions : 0
|
||||
entry.unstagedDeletions = Number.isFinite(deletions) ? deletions : 0
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function getWorktreeGitStatus(params: {
|
||||
workspaceFolder: string
|
||||
logger?: LogLike
|
||||
}): Promise<WorktreeGitStatusEntry[]> {
|
||||
const { workspaceFolder, logger } = params
|
||||
const [stagedResult, unstagedResult, untrackedResult, stagedNumstatResult, unstagedNumstatResult] = await Promise.all([
|
||||
runGit(["diff", "--name-status", "-z", "--cached", "--find-renames", "--find-copies"], workspaceFolder),
|
||||
runGit(["diff", "--name-status", "-z", "--find-renames", "--find-copies"], workspaceFolder),
|
||||
runGit(["ls-files", "--others", "--exclude-standard"], workspaceFolder),
|
||||
runGit(["diff", "--numstat", "-z", "--cached", "--find-renames", "--find-copies"], workspaceFolder),
|
||||
runGit(["diff", "--numstat", "-z", "--find-renames", "--find-copies"], workspaceFolder),
|
||||
])
|
||||
|
||||
for (const result of [stagedResult, unstagedResult, untrackedResult, stagedNumstatResult, unstagedNumstatResult]) {
|
||||
if (!result.ok) {
|
||||
logger?.warn?.({ workspaceFolder, err: result.error }, "Failed to read git status for worktree")
|
||||
throw result.error
|
||||
}
|
||||
}
|
||||
|
||||
const stagedOutput = (stagedResult as GitSuccessResult).stdout
|
||||
const unstagedOutput = (unstagedResult as GitSuccessResult).stdout
|
||||
const untrackedOutput = (untrackedResult as GitSuccessResult).stdout
|
||||
const stagedNumstatOutput = (stagedNumstatResult as GitSuccessResult).stdout
|
||||
const unstagedNumstatOutput = (unstagedNumstatResult as GitSuccessResult).stdout
|
||||
|
||||
const entries = new Map<string, WorktreeGitStatusEntry>()
|
||||
applyNameStatusOutput(entries, stagedOutput, "stagedStatus")
|
||||
applyNameStatusOutput(entries, unstagedOutput, "unstagedStatus")
|
||||
applyUntrackedOutput(entries, untrackedOutput)
|
||||
applyNumstatOutput(entries, stagedNumstatOutput, "staged")
|
||||
applyNumstatOutput(entries, unstagedNumstatOutput, "unstaged")
|
||||
await applyUntrackedFileStats(entries, workspaceFolder)
|
||||
|
||||
return Array.from(entries.values()).sort((a, b) => a.path.localeCompare(b.path))
|
||||
}
|
||||
|
||||
function decodeGitShowResult(result: GitResult, missingOk = false): string {
|
||||
if (result.ok) return result.stdout
|
||||
const message = result.stderr?.trim() || result.error.message || ""
|
||||
if (
|
||||
missingOk &&
|
||||
(message.includes("exists on disk, but not in") ||
|
||||
message.includes("Path '") ||
|
||||
message.includes("does not exist") ||
|
||||
message.includes("unknown revision or path not in the working tree"))
|
||||
) {
|
||||
return ""
|
||||
}
|
||||
throw result.error
|
||||
}
|
||||
|
||||
async function readGitIndexBlob(workspaceFolder: string, normalizedPath: string): Promise<GitResult> {
|
||||
return runGit(["cat-file", "-p", `:${normalizedPath}`], workspaceFolder)
|
||||
}
|
||||
|
||||
async function getTrackedDiffMetadata(params: {
|
||||
workspaceFolder: string
|
||||
scope: WorktreeGitDiffScope
|
||||
normalizedPath: string
|
||||
normalizedOriginalPath: string | null
|
||||
}): Promise<{ isBinary: boolean; found: boolean }> {
|
||||
const args = ["diff", "--numstat"]
|
||||
if (params.scope === "staged") {
|
||||
args.push("--cached")
|
||||
}
|
||||
args.push("--find-renames", "--find-copies", "--")
|
||||
args.push(params.normalizedPath)
|
||||
if (params.normalizedOriginalPath && params.normalizedOriginalPath !== params.normalizedPath) {
|
||||
args.push(params.normalizedOriginalPath)
|
||||
}
|
||||
|
||||
const result = await runGit(args, params.workspaceFolder)
|
||||
if (!result.ok) {
|
||||
throw result.error
|
||||
}
|
||||
|
||||
const parsed = parseSingleNumstat(result.stdout)
|
||||
return { isBinary: parsed.isBinary, found: parsed.found }
|
||||
}
|
||||
|
||||
async function getUntrackedDiffMetadata(params: {
|
||||
workspaceFolder: string
|
||||
normalizedPath: string
|
||||
}): Promise<{ isBinary: boolean }> {
|
||||
const absolutePath = path.join(params.workspaceFolder, params.normalizedPath)
|
||||
const result = await runGit(["diff", "--numstat", "--no-index", "--", "/dev/null", absolutePath], params.workspaceFolder, [0, 1])
|
||||
if (!result.ok) {
|
||||
throw result.error
|
||||
}
|
||||
|
||||
return { isBinary: parseSingleNumstat(result.stdout).isBinary }
|
||||
}
|
||||
|
||||
async function resolveUnstagedBeforePath(params: {
|
||||
workspaceFolder: string
|
||||
normalizedPath: string
|
||||
normalizedOriginalPath: string | null
|
||||
}): Promise<GitResult> {
|
||||
const currentPathResult = await readGitIndexBlob(params.workspaceFolder, params.normalizedPath)
|
||||
if (currentPathResult.ok || !params.normalizedOriginalPath || params.normalizedOriginalPath === params.normalizedPath) {
|
||||
return currentPathResult
|
||||
}
|
||||
return readGitIndexBlob(params.workspaceFolder, params.normalizedOriginalPath)
|
||||
}
|
||||
|
||||
export async function getWorktreeGitDiff(params: {
|
||||
workspaceFolder: string
|
||||
path: string
|
||||
originalPath?: string | null
|
||||
scope: WorktreeGitDiffScope
|
||||
}): Promise<WorktreeGitDiffResponse> {
|
||||
const normalizedPath = normalizeGitWorktreeRelativePath(params.path)
|
||||
const normalizedOriginalPath = params.originalPath ? normalizeGitWorktreeRelativePath(params.originalPath) : null
|
||||
|
||||
const trackedMetadata = await getTrackedDiffMetadata({
|
||||
workspaceFolder: params.workspaceFolder,
|
||||
scope: params.scope,
|
||||
normalizedPath,
|
||||
normalizedOriginalPath,
|
||||
})
|
||||
|
||||
const diffMetadata =
|
||||
params.scope === "unstaged" && !trackedMetadata.found
|
||||
? await getUntrackedDiffMetadata({
|
||||
workspaceFolder: params.workspaceFolder,
|
||||
normalizedPath,
|
||||
})
|
||||
: trackedMetadata
|
||||
|
||||
if (diffMetadata.isBinary) {
|
||||
return {
|
||||
path: normalizedPath,
|
||||
originalPath: normalizedOriginalPath,
|
||||
scope: params.scope,
|
||||
before: "",
|
||||
after: "",
|
||||
isBinary: true,
|
||||
}
|
||||
}
|
||||
|
||||
if (params.scope === "staged") {
|
||||
const [beforeResult, afterResult] = await Promise.all([
|
||||
readGitBlobAsDiffText(runGit(["show", `HEAD:${normalizedOriginalPath ?? normalizedPath}`], params.workspaceFolder), true),
|
||||
readGitBlobAsDiffText(readGitIndexBlob(params.workspaceFolder, normalizedPath), true),
|
||||
])
|
||||
|
||||
return {
|
||||
path: normalizedPath,
|
||||
originalPath: normalizedOriginalPath,
|
||||
scope: params.scope,
|
||||
before: beforeResult,
|
||||
after: afterResult,
|
||||
isBinary: false,
|
||||
}
|
||||
}
|
||||
|
||||
const indexResult = await resolveUnstagedBeforePath({
|
||||
workspaceFolder: params.workspaceFolder,
|
||||
normalizedPath,
|
||||
normalizedOriginalPath,
|
||||
})
|
||||
|
||||
const beforeResult = await readGitBlobAsDiffText(Promise.resolve(indexResult), true)
|
||||
let after = beforeResult
|
||||
|
||||
const fsPath = path.join(params.workspaceFolder, normalizedPath)
|
||||
try {
|
||||
after = await readFileAsDiffText(fsPath)
|
||||
} catch {
|
||||
after = ""
|
||||
}
|
||||
|
||||
return {
|
||||
path: normalizedPath,
|
||||
originalPath: normalizedOriginalPath,
|
||||
scope: params.scope,
|
||||
before: beforeResult,
|
||||
after,
|
||||
isBinary: false,
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,10 @@ export interface LogLike {
|
||||
|
||||
type GitResult = { ok: true; stdout: string } | { ok: false; error: Error; stdout?: string; stderr?: string }
|
||||
|
||||
function isGitUnavailableResult(result: GitResult): boolean {
|
||||
return !result.ok && (result.error as NodeJS.ErrnoException | undefined)?.code === "ENOENT"
|
||||
}
|
||||
|
||||
function runGit(args: string[], cwd: string): Promise<GitResult> {
|
||||
return new Promise((resolve) => {
|
||||
const child = spawn("git", args, { cwd, stdio: ["ignore", "pipe", "pipe"] })
|
||||
@@ -38,6 +42,9 @@ function runGit(args: string[], cwd: string): Promise<GitResult> {
|
||||
|
||||
export async function resolveRepoRoot(folder: string, logger?: LogLike): Promise<{ repoRoot: string; isGitRepo: boolean }> {
|
||||
const result = await runGit(["rev-parse", "--show-toplevel"], folder)
|
||||
if (isGitUnavailableResult(result)) {
|
||||
throw new Error("Git is not installed or not available in PATH")
|
||||
}
|
||||
if (!result.ok) {
|
||||
logger?.debug?.({ folder, err: result.error }, "Folder is not a Git repository; using workspace folder as root")
|
||||
return { repoRoot: folder, isGitRepo: false }
|
||||
@@ -49,6 +56,11 @@ export async function resolveRepoRoot(folder: string, logger?: LogLike): Promise
|
||||
return { repoRoot, isGitRepo: true }
|
||||
}
|
||||
|
||||
export async function isGitAvailable(folder: string): Promise<boolean> {
|
||||
const result = await runGit(["--version"], folder)
|
||||
return result.ok || !isGitUnavailableResult(result)
|
||||
}
|
||||
|
||||
function parseWorktreePorcelain(output: string): Array<{ worktree: string; branch?: string; head?: string; detached?: boolean }> {
|
||||
const records: Array<{ worktree: string; branch?: string; head?: string; detached?: boolean }> = []
|
||||
const lines = output.split(/\r?\n/)
|
||||
@@ -90,15 +102,22 @@ export async function listWorktrees(params: {
|
||||
logger?: LogLike
|
||||
}): Promise<WorktreeDescriptor[]> {
|
||||
const { repoRoot, workspaceFolder, logger } = params
|
||||
const rootDescriptor: WorktreeDescriptor = { slug: "root", directory: repoRoot, kind: "root" }
|
||||
|
||||
const result = await runGit(["worktree", "list", "--porcelain"], workspaceFolder)
|
||||
if (!result.ok) {
|
||||
const rootDescriptor: WorktreeDescriptor = { slug: "root", directory: repoRoot, kind: "root" }
|
||||
logger?.debug?.({ repoRoot, err: result.error }, "Failed to list git worktrees; returning root only")
|
||||
return [rootDescriptor]
|
||||
}
|
||||
|
||||
const records = parseWorktreePorcelain(result.stdout)
|
||||
const rootRecord = records.find((record) => path.resolve(record.worktree) === path.resolve(repoRoot))
|
||||
const rootDescriptor: WorktreeDescriptor = {
|
||||
slug: "root",
|
||||
directory: repoRoot,
|
||||
kind: "root",
|
||||
branch: rootRecord?.branch,
|
||||
}
|
||||
|
||||
const worktrees: WorktreeDescriptor[] = [rootDescriptor]
|
||||
const seen = new Set<string>(["root"])
|
||||
|
||||
99
packages/server/src/workspaces/worktree-directory.ts
Normal file
99
packages/server/src/workspaces/worktree-directory.ts
Normal file
@@ -0,0 +1,99 @@
|
||||
import { realpath } from "fs/promises"
|
||||
import type { LogLike } from "./git-worktrees"
|
||||
import { listWorktrees, resolveRepoRoot } from "./git-worktrees"
|
||||
|
||||
type WorktreeCacheEntry = {
|
||||
expiresAt: number
|
||||
repoRoot: string
|
||||
worktrees: Array<{ slug: string; directory: string; normalizedDirectory: string }>
|
||||
}
|
||||
|
||||
const WORKTREE_CACHE_TTL_MS = 2000
|
||||
const worktreeCache = new Map<string, WorktreeCacheEntry>()
|
||||
|
||||
async function normalizeDirectoryPath(directory: string): Promise<string> {
|
||||
const trimmed = (directory ?? "").trim()
|
||||
if (!trimmed) return ""
|
||||
try {
|
||||
return await realpath(trimmed)
|
||||
} catch {
|
||||
return trimmed
|
||||
}
|
||||
}
|
||||
|
||||
async function getCachedWorktrees(params: { workspaceId: string; workspacePath: string; logger?: LogLike }) {
|
||||
const cached = worktreeCache.get(params.workspaceId)
|
||||
const now = Date.now()
|
||||
if (cached && cached.expiresAt > now) {
|
||||
return cached
|
||||
}
|
||||
|
||||
const { repoRoot } = await resolveRepoRoot(params.workspacePath, params.logger)
|
||||
const worktrees = await listWorktrees({ repoRoot, workspaceFolder: params.workspacePath, logger: params.logger })
|
||||
const entry: WorktreeCacheEntry = {
|
||||
expiresAt: now + WORKTREE_CACHE_TTL_MS,
|
||||
repoRoot,
|
||||
worktrees: await Promise.all(
|
||||
worktrees.map(async (wt) => ({
|
||||
slug: wt.slug,
|
||||
directory: wt.directory,
|
||||
normalizedDirectory: await normalizeDirectoryPath(wt.directory),
|
||||
})),
|
||||
),
|
||||
}
|
||||
worktreeCache.set(params.workspaceId, entry)
|
||||
return entry
|
||||
}
|
||||
|
||||
export async function resolveWorktreeDirectory(params: {
|
||||
workspaceId: string
|
||||
workspacePath: string
|
||||
worktreeSlug: string
|
||||
logger?: LogLike
|
||||
}): Promise<string | null> {
|
||||
const cached = await getCachedWorktrees({
|
||||
workspaceId: params.workspaceId,
|
||||
workspacePath: params.workspacePath,
|
||||
logger: params.logger,
|
||||
})
|
||||
const match = cached.worktrees.find((wt) => wt.slug === params.worktreeSlug)
|
||||
if (match) {
|
||||
return match.directory
|
||||
}
|
||||
|
||||
worktreeCache.delete(params.workspaceId)
|
||||
const refreshed = await getCachedWorktrees({
|
||||
workspaceId: params.workspaceId,
|
||||
workspacePath: params.workspacePath,
|
||||
logger: params.logger,
|
||||
})
|
||||
return refreshed.worktrees.find((wt) => wt.slug === params.worktreeSlug)?.directory ?? null
|
||||
}
|
||||
|
||||
export async function resolveWorktreeSlugForDirectory(params: {
|
||||
workspaceId: string
|
||||
workspacePath: string
|
||||
directory: string
|
||||
logger?: LogLike
|
||||
}): Promise<string | null> {
|
||||
const target = await normalizeDirectoryPath(params.directory ?? "")
|
||||
if (!target) return null
|
||||
|
||||
const cached = await getCachedWorktrees({
|
||||
workspaceId: params.workspaceId,
|
||||
workspacePath: params.workspacePath,
|
||||
logger: params.logger,
|
||||
})
|
||||
const match = cached.worktrees.find((wt) => wt.normalizedDirectory === target)
|
||||
if (match) {
|
||||
return match.slug
|
||||
}
|
||||
|
||||
worktreeCache.delete(params.workspaceId)
|
||||
const refreshed = await getCachedWorktrees({
|
||||
workspaceId: params.workspaceId,
|
||||
workspacePath: params.workspacePath,
|
||||
logger: params.logger,
|
||||
})
|
||||
return refreshed.worktrees.find((wt) => wt.normalizedDirectory === target)?.slug ?? null
|
||||
}
|
||||
517
packages/tauri-app/Cargo.lock
generated
517
packages/tauri-app/Cargo.lock
generated
@@ -47,6 +47,15 @@ version = "1.0.102"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c"
|
||||
|
||||
[[package]]
|
||||
name = "arc-swap"
|
||||
version = "1.9.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6a3a1fd6f75306b68087b831f025c712524bcb19aad54e557b1129cfa0a2b207"
|
||||
dependencies = [
|
||||
"rustversion",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "async-broadcast"
|
||||
version = "0.7.2"
|
||||
@@ -213,6 +222,105 @@ version = "1.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8"
|
||||
|
||||
[[package]]
|
||||
name = "aws-lc-rs"
|
||||
version = "1.16.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a054912289d18629dc78375ba2c3726a3afe3ff71b4edba9dedfca0e3446d1fc"
|
||||
dependencies = [
|
||||
"aws-lc-sys",
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "aws-lc-sys"
|
||||
version = "0.39.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "83a25cf98105baa966497416dbd42565ce3a8cf8dbfd59803ec9ad46f3126399"
|
||||
dependencies = [
|
||||
"cc",
|
||||
"cmake",
|
||||
"dunce",
|
||||
"fs_extra",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "axum"
|
||||
version = "0.7.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "edca88bc138befd0323b20752846e6587272d3b03b0343c8ea28a6f819e6e71f"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"axum-core",
|
||||
"bytes",
|
||||
"futures-util",
|
||||
"http",
|
||||
"http-body",
|
||||
"http-body-util",
|
||||
"hyper",
|
||||
"hyper-util",
|
||||
"itoa",
|
||||
"matchit",
|
||||
"memchr",
|
||||
"mime",
|
||||
"percent-encoding",
|
||||
"pin-project-lite",
|
||||
"rustversion",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"serde_path_to_error",
|
||||
"serde_urlencoded",
|
||||
"sync_wrapper",
|
||||
"tokio",
|
||||
"tower",
|
||||
"tower-layer",
|
||||
"tower-service",
|
||||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "axum-core"
|
||||
version = "0.4.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "09f2bd6146b97ae3359fa0cc6d6b376d9539582c7b4220f041a33ec24c226199"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"bytes",
|
||||
"futures-util",
|
||||
"http",
|
||||
"http-body",
|
||||
"http-body-util",
|
||||
"mime",
|
||||
"pin-project-lite",
|
||||
"rustversion",
|
||||
"sync_wrapper",
|
||||
"tower-layer",
|
||||
"tower-service",
|
||||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "axum-server"
|
||||
version = "0.7.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c1ab4a3ec9ea8a657c72d99a03a824af695bd0fb5ec639ccbd9cd3543b41a5f9"
|
||||
dependencies = [
|
||||
"arc-swap",
|
||||
"bytes",
|
||||
"fs-err",
|
||||
"http",
|
||||
"http-body",
|
||||
"hyper",
|
||||
"hyper-util",
|
||||
"pin-project-lite",
|
||||
"rustls",
|
||||
"rustls-pemfile",
|
||||
"rustls-pki-types",
|
||||
"tokio",
|
||||
"tokio-rustls",
|
||||
"tower-service",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "base64"
|
||||
version = "0.21.7"
|
||||
@@ -408,6 +516,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7a0dd1ca384932ff3641c8718a02769f1698e7563dc6974ffd03346116310423"
|
||||
dependencies = [
|
||||
"find-msvc-tools",
|
||||
"jobserver",
|
||||
"libc",
|
||||
"shlex",
|
||||
]
|
||||
|
||||
@@ -444,6 +554,12 @@ version = "1.0.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
|
||||
|
||||
[[package]]
|
||||
name = "cfg_aliases"
|
||||
version = "0.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724"
|
||||
|
||||
[[package]]
|
||||
name = "chrono"
|
||||
version = "0.4.44"
|
||||
@@ -456,17 +572,34 @@ dependencies = [
|
||||
"windows-link 0.2.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cmake"
|
||||
version = "0.1.58"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c0f78a02292a74a88ac736019ab962ece0bc380e3f977bf72e376c5d78ff0678"
|
||||
dependencies = [
|
||||
"cc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "codenomad-tauri"
|
||||
version = "0.13.3"
|
||||
version = "0.14.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"axum",
|
||||
"axum-server",
|
||||
"base64 0.22.1",
|
||||
"bytes",
|
||||
"dirs 5.0.1",
|
||||
"futures-util",
|
||||
"keepawake",
|
||||
"libc",
|
||||
"once_cell",
|
||||
"parking_lot",
|
||||
"rand 0.8.5",
|
||||
"regex",
|
||||
"reqwest 0.12.28",
|
||||
"rustls",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"serde_yaml",
|
||||
@@ -477,7 +610,9 @@ dependencies = [
|
||||
"tauri-plugin-notification",
|
||||
"tauri-plugin-opener",
|
||||
"thiserror 1.0.69",
|
||||
"tokio",
|
||||
"url",
|
||||
"webkit2gtk",
|
||||
"which",
|
||||
"windows-sys 0.59.0",
|
||||
]
|
||||
@@ -969,6 +1104,15 @@ version = "1.2.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4ef6b89e5b37196644d8796de5268852ff179b44e96276cf4290264843743bb7"
|
||||
|
||||
[[package]]
|
||||
name = "encoding_rs"
|
||||
version = "0.8.35"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "endi"
|
||||
version = "1.1.1"
|
||||
@@ -1139,6 +1283,22 @@ dependencies = [
|
||||
"percent-encoding",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fs-err"
|
||||
version = "3.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "73fde052dbfc920003cfd2c8e2c6e6d4cc7c1091538c3a24226cec0665ab08c0"
|
||||
dependencies = [
|
||||
"autocfg",
|
||||
"tokio",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fs_extra"
|
||||
version = "1.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c"
|
||||
|
||||
[[package]]
|
||||
name = "futf"
|
||||
version = "0.1.5"
|
||||
@@ -1379,8 +1539,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"js-sys",
|
||||
"libc",
|
||||
"wasi 0.11.1+wasi-snapshot-preview1",
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1390,9 +1552,11 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"js-sys",
|
||||
"libc",
|
||||
"r-efi 5.3.0",
|
||||
"wasip2",
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1574,6 +1738,25 @@ dependencies = [
|
||||
"syn 2.0.117",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "h2"
|
||||
version = "0.4.13"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2f44da3a8150a6703ed5d34e164b875fd14c2cdab9af1252a9a1020bde2bdc54"
|
||||
dependencies = [
|
||||
"atomic-waker",
|
||||
"bytes",
|
||||
"fnv",
|
||||
"futures-core",
|
||||
"futures-sink",
|
||||
"http",
|
||||
"indexmap 2.13.0",
|
||||
"slab",
|
||||
"tokio",
|
||||
"tokio-util",
|
||||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hashbrown"
|
||||
version = "0.12.3"
|
||||
@@ -1689,6 +1872,12 @@ version = "1.10.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87"
|
||||
|
||||
[[package]]
|
||||
name = "httpdate"
|
||||
version = "1.0.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9"
|
||||
|
||||
[[package]]
|
||||
name = "hyper"
|
||||
version = "1.8.1"
|
||||
@@ -1699,9 +1888,11 @@ dependencies = [
|
||||
"bytes",
|
||||
"futures-channel",
|
||||
"futures-core",
|
||||
"h2",
|
||||
"http",
|
||||
"http-body",
|
||||
"httparse",
|
||||
"httpdate",
|
||||
"itoa",
|
||||
"pin-project-lite",
|
||||
"pin-utils",
|
||||
@@ -1710,6 +1901,23 @@ dependencies = [
|
||||
"want",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hyper-rustls"
|
||||
version = "0.27.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e3c93eb611681b207e1fe55d5a71ecf91572ec8a6705cdb6857f7d8d5242cf58"
|
||||
dependencies = [
|
||||
"http",
|
||||
"hyper",
|
||||
"hyper-util",
|
||||
"rustls",
|
||||
"rustls-pki-types",
|
||||
"tokio",
|
||||
"tokio-rustls",
|
||||
"tower-service",
|
||||
"webpki-roots",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hyper-util"
|
||||
version = "0.1.20"
|
||||
@@ -1999,6 +2207,16 @@ version = "0.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8eaf4bc02d17cbdd7ff4c7438cafcdf7fb9a4613313ad11b4f8fefe7d3fa0130"
|
||||
|
||||
[[package]]
|
||||
name = "jobserver"
|
||||
version = "0.1.34"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33"
|
||||
dependencies = [
|
||||
"getrandom 0.3.4",
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "js-sys"
|
||||
version = "0.3.91"
|
||||
@@ -2157,6 +2375,12 @@ version = "0.4.29"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897"
|
||||
|
||||
[[package]]
|
||||
name = "lru-slab"
|
||||
version = "0.1.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154"
|
||||
|
||||
[[package]]
|
||||
name = "mac"
|
||||
version = "0.1.1"
|
||||
@@ -2217,6 +2441,12 @@ version = "0.1.10"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2532096657941c2fea9c289d370a250971c689d4f143798ff67113ec042024a5"
|
||||
|
||||
[[package]]
|
||||
name = "matchit"
|
||||
version = "0.7.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0e7465ac9959cc2b1404e8e2367b43684a6d13790fe23056cc8c6c5a6b7bcb94"
|
||||
|
||||
[[package]]
|
||||
name = "memchr"
|
||||
version = "2.8.0"
|
||||
@@ -2995,6 +3225,61 @@ dependencies = [
|
||||
"memchr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "quinn"
|
||||
version = "0.11.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b9e20a958963c291dc322d98411f541009df2ced7b5a4f2bd52337638cfccf20"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"cfg_aliases",
|
||||
"pin-project-lite",
|
||||
"quinn-proto",
|
||||
"quinn-udp",
|
||||
"rustc-hash",
|
||||
"rustls",
|
||||
"socket2",
|
||||
"thiserror 2.0.18",
|
||||
"tokio",
|
||||
"tracing",
|
||||
"web-time",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "quinn-proto"
|
||||
version = "0.11.14"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "434b42fec591c96ef50e21e886936e66d3cc3f737104fdb9b737c40ffb94c098"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"getrandom 0.3.4",
|
||||
"lru-slab",
|
||||
"rand 0.9.2",
|
||||
"ring",
|
||||
"rustc-hash",
|
||||
"rustls",
|
||||
"rustls-pki-types",
|
||||
"slab",
|
||||
"thiserror 2.0.18",
|
||||
"tinyvec",
|
||||
"tracing",
|
||||
"web-time",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "quinn-udp"
|
||||
version = "0.5.14"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "addec6a0dcad8a8d96a771f815f0eaf55f9d1805756410b39f5fa81332574cbd"
|
||||
dependencies = [
|
||||
"cfg_aliases",
|
||||
"libc",
|
||||
"once_cell",
|
||||
"socket2",
|
||||
"tracing",
|
||||
"windows-sys 0.60.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "quote"
|
||||
version = "1.0.45"
|
||||
@@ -3212,6 +3497,50 @@ version = "0.8.10"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a"
|
||||
|
||||
[[package]]
|
||||
name = "reqwest"
|
||||
version = "0.12.28"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"bytes",
|
||||
"encoding_rs",
|
||||
"futures-core",
|
||||
"futures-util",
|
||||
"h2",
|
||||
"http",
|
||||
"http-body",
|
||||
"http-body-util",
|
||||
"hyper",
|
||||
"hyper-rustls",
|
||||
"hyper-util",
|
||||
"js-sys",
|
||||
"log",
|
||||
"mime",
|
||||
"percent-encoding",
|
||||
"pin-project-lite",
|
||||
"quinn",
|
||||
"rustls",
|
||||
"rustls-pki-types",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"serde_urlencoded",
|
||||
"sync_wrapper",
|
||||
"tokio",
|
||||
"tokio-rustls",
|
||||
"tokio-util",
|
||||
"tower",
|
||||
"tower-http",
|
||||
"tower-service",
|
||||
"url",
|
||||
"wasm-bindgen",
|
||||
"wasm-bindgen-futures",
|
||||
"wasm-streams 0.4.2",
|
||||
"web-sys",
|
||||
"webpki-roots",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "reqwest"
|
||||
version = "0.13.2"
|
||||
@@ -3242,7 +3571,7 @@ dependencies = [
|
||||
"url",
|
||||
"wasm-bindgen",
|
||||
"wasm-bindgen-futures",
|
||||
"wasm-streams",
|
||||
"wasm-streams 0.5.0",
|
||||
"web-sys",
|
||||
]
|
||||
|
||||
@@ -3270,6 +3599,20 @@ dependencies = [
|
||||
"windows-sys 0.60.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ring"
|
||||
version = "0.17.14"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7"
|
||||
dependencies = [
|
||||
"cc",
|
||||
"cfg-if",
|
||||
"getrandom 0.2.17",
|
||||
"libc",
|
||||
"untrusted",
|
||||
"windows-sys 0.52.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustc-hash"
|
||||
version = "2.1.1"
|
||||
@@ -3311,6 +3654,53 @@ dependencies = [
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustls"
|
||||
version = "0.23.37"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "758025cb5fccfd3bc2fd74708fd4682be41d99e5dff73c377c0646c6012c73a4"
|
||||
dependencies = [
|
||||
"aws-lc-rs",
|
||||
"log",
|
||||
"once_cell",
|
||||
"ring",
|
||||
"rustls-pki-types",
|
||||
"rustls-webpki",
|
||||
"subtle",
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustls-pemfile"
|
||||
version = "2.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "dce314e5fee3f39953d46bb63bb8a46d40c2f8fb7cc5a3b6cab2bde9721d6e50"
|
||||
dependencies = [
|
||||
"rustls-pki-types",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustls-pki-types"
|
||||
version = "1.14.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "be040f8b0a225e40375822a563fa9524378b9d63112f53e19ffff34df5d33fdd"
|
||||
dependencies = [
|
||||
"web-time",
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustls-webpki"
|
||||
version = "0.103.10"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "df33b2b81ac578cabaf06b89b0631153a3f416b0a886e8a7a1707fb51abbd1ef"
|
||||
dependencies = [
|
||||
"aws-lc-rs",
|
||||
"ring",
|
||||
"rustls-pki-types",
|
||||
"untrusted",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustversion"
|
||||
version = "1.0.22"
|
||||
@@ -3502,6 +3892,17 @@ dependencies = [
|
||||
"zmij",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_path_to_error"
|
||||
version = "0.1.20"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457"
|
||||
dependencies = [
|
||||
"itoa",
|
||||
"serde",
|
||||
"serde_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_repr"
|
||||
version = "0.1.20"
|
||||
@@ -3531,6 +3932,18 @@ dependencies = [
|
||||
"serde_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_urlencoded"
|
||||
version = "0.7.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd"
|
||||
dependencies = [
|
||||
"form_urlencoded",
|
||||
"itoa",
|
||||
"ryu",
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_with"
|
||||
version = "3.18.0"
|
||||
@@ -3792,6 +4205,12 @@ version = "0.11.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
|
||||
|
||||
[[package]]
|
||||
name = "subtle"
|
||||
version = "2.6.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292"
|
||||
|
||||
[[package]]
|
||||
name = "swift-rs"
|
||||
version = "1.0.7"
|
||||
@@ -3943,7 +4362,7 @@ dependencies = [
|
||||
"percent-encoding",
|
||||
"plist",
|
||||
"raw-window-handle",
|
||||
"reqwest",
|
||||
"reqwest 0.13.2",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"serde_repr",
|
||||
@@ -4367,6 +4786,21 @@ dependencies = [
|
||||
"zerovec",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tinyvec"
|
||||
version = "1.11.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3e61e67053d25a4e82c844e8424039d9745781b3fc4f32b8d55ed50f5f667ef3"
|
||||
dependencies = [
|
||||
"tinyvec_macros",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tinyvec_macros"
|
||||
version = "0.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20"
|
||||
|
||||
[[package]]
|
||||
name = "tokio"
|
||||
version = "1.50.0"
|
||||
@@ -4378,9 +4812,31 @@ dependencies = [
|
||||
"mio",
|
||||
"pin-project-lite",
|
||||
"socket2",
|
||||
"tokio-macros",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tokio-macros"
|
||||
version = "2.6.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5c55a2eff8b69ce66c84f85e1da1c233edc36ceb85a2058d11b0d6a3c7e7569c"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.117",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tokio-rustls"
|
||||
version = "0.26.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61"
|
||||
dependencies = [
|
||||
"rustls",
|
||||
"tokio",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tokio-util"
|
||||
version = "0.7.18"
|
||||
@@ -4512,6 +4968,7 @@ dependencies = [
|
||||
"tokio",
|
||||
"tower-layer",
|
||||
"tower-service",
|
||||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -4550,6 +5007,7 @@ version = "0.1.44"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100"
|
||||
dependencies = [
|
||||
"log",
|
||||
"pin-project-lite",
|
||||
"tracing-attributes",
|
||||
"tracing-core",
|
||||
@@ -4691,6 +5149,12 @@ version = "0.2.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "673aac59facbab8a9007c7f6108d11f63b603f7cabff99fabf650fea5c32b861"
|
||||
|
||||
[[package]]
|
||||
name = "untrusted"
|
||||
version = "0.9.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1"
|
||||
|
||||
[[package]]
|
||||
name = "url"
|
||||
version = "2.5.8"
|
||||
@@ -4902,6 +5366,19 @@ dependencies = [
|
||||
"wasmparser",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasm-streams"
|
||||
version = "0.4.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "15053d8d85c7eccdbefef60f06769760a563c7f0a9d6902a13d35c7800b0ad65"
|
||||
dependencies = [
|
||||
"futures-util",
|
||||
"js-sys",
|
||||
"wasm-bindgen",
|
||||
"wasm-bindgen-futures",
|
||||
"web-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasm-streams"
|
||||
version = "0.5.0"
|
||||
@@ -4937,6 +5414,16 @@ dependencies = [
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "web-time"
|
||||
version = "1.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb"
|
||||
dependencies = [
|
||||
"js-sys",
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "web_atoms"
|
||||
version = "0.2.3"
|
||||
@@ -4993,6 +5480,15 @@ dependencies = [
|
||||
"system-deps",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "webpki-roots"
|
||||
version = "1.0.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "22cfaf3c063993ff62e73cb4311efde4db1efb31ab78a3e5c457939ad5cc0bed"
|
||||
dependencies = [
|
||||
"rustls-pki-types",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "webview2-com"
|
||||
version = "0.38.2"
|
||||
@@ -5286,6 +5782,15 @@ dependencies = [
|
||||
"windows-targets 0.48.5",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-sys"
|
||||
version = "0.52.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d"
|
||||
dependencies = [
|
||||
"windows-targets 0.52.6",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-sys"
|
||||
version = "0.59.0"
|
||||
@@ -5927,6 +6432,12 @@ dependencies = [
|
||||
"synstructure",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zeroize"
|
||||
version = "1.8.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0"
|
||||
|
||||
[[package]]
|
||||
name = "zerotrie"
|
||||
version = "0.2.3"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@codenomad/tauri-app",
|
||||
"version": "0.13.3",
|
||||
"version": "0.14.0",
|
||||
"private": true,
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
@@ -14,6 +14,7 @@
|
||||
"build": "tauri build"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tauri-apps/cli": "^2.9.4"
|
||||
"@tauri-apps/cli": "^2.9.4",
|
||||
"@tauri-apps/cli-darwin-arm64": "^2.9.4"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "codenomad-tauri"
|
||||
version = "0.13.3"
|
||||
version = "0.14.0"
|
||||
edition = "2021"
|
||||
license = "MIT"
|
||||
|
||||
@@ -12,11 +12,20 @@ tauri = { version = "2.5.2", features = [ "devtools"] }
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
serde_yaml = "0.9"
|
||||
axum = "0.7"
|
||||
axum-server = { version = "0.7", features = ["tls-rustls"] }
|
||||
base64 = "0.22"
|
||||
bytes = "1"
|
||||
futures-util = "0.3"
|
||||
rustls = { version = "0.23", features = ["ring"] }
|
||||
reqwest = { version = "0.12", default-features = false, features = ["http2", "charset", "json", "stream", "rustls-tls"] }
|
||||
rand = "0.8"
|
||||
regex = "1"
|
||||
once_cell = "1"
|
||||
parking_lot = "0.12"
|
||||
thiserror = "1"
|
||||
anyhow = "1"
|
||||
tokio = { version = "1", features = ["macros", "rt-multi-thread", "net", "sync"] }
|
||||
which = "4"
|
||||
libc = "0.2"
|
||||
keepawake = "0.6"
|
||||
@@ -28,4 +37,7 @@ url = "2"
|
||||
tauri-plugin-notification = "2"
|
||||
|
||||
[target.'cfg(windows)'.dependencies]
|
||||
windows-sys = { version = "0.59", features = ["Win32_UI_Shell"] }
|
||||
windows-sys = { version = "0.59", features = ["Win32_Foundation", "Win32_Security_Cryptography", "Win32_UI_Shell", "Win32_Security", "Win32_System_JobObjects"] }
|
||||
|
||||
[target.'cfg(target_os = "linux")'.dependencies]
|
||||
webkit2gtk = "2.0.2"
|
||||
|
||||
2807
packages/tauri-app/src-tauri/gen/schemas/windows-schema.json
Normal file
2807
packages/tauri-app/src-tauri/gen/schemas/windows-schema.json
Normal file
File diff suppressed because it is too large
Load Diff
248
packages/tauri-app/src-tauri/src/cert_manager.rs
Normal file
248
packages/tauri-app/src-tauri/src/cert_manager.rs
Normal file
@@ -0,0 +1,248 @@
|
||||
use base64::Engine;
|
||||
use std::env;
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
const DEFAULT_CONFIG_PATH: &str = "~/.config/codenomad/config.json";
|
||||
const TLS_DIR_NAME: &str = "tls";
|
||||
const CA_CERT_FILE: &str = "ca-cert.pem";
|
||||
const SERVER_CERT_FILE: &str = "server-cert.pem";
|
||||
const SERVER_KEY_FILE: &str = "server-key.pem";
|
||||
const TRUSTED_MARKER: &str = "server-ca.trusted";
|
||||
#[cfg(windows)]
|
||||
const WINDOWS_APP_USER_MODEL_ID: &str = "ai.neuralnomads.codenomad.client";
|
||||
|
||||
/// Holds the PEM-encoded certificate/key pair used by the local HTTPS proxy,
|
||||
/// plus the CA certificate DER used for trust-store installation.
|
||||
pub struct LocalCert {
|
||||
pub cert_pem: String,
|
||||
pub key_pem: String,
|
||||
pub ca_cert_der: Vec<u8>,
|
||||
}
|
||||
|
||||
struct TlsAssetPaths {
|
||||
cert_path: PathBuf,
|
||||
key_path: PathBuf,
|
||||
trust_path: PathBuf,
|
||||
append_ca_to_cert: bool,
|
||||
}
|
||||
|
||||
/// Loads the TLS assets already managed by `packages/server`.
|
||||
pub fn ensure_local_cert() -> Result<LocalCert, String> {
|
||||
let assets = resolve_tls_asset_paths()?;
|
||||
let mut cert_pem = read_pem_file(&assets.cert_path)?;
|
||||
let key_pem = read_pem_file(&assets.key_path)?;
|
||||
let trust_pem = read_pem_file(&assets.trust_path)?;
|
||||
|
||||
if assets.append_ca_to_cert {
|
||||
cert_pem = format!("{}\n{}\n", cert_pem.trim(), trust_pem.trim());
|
||||
}
|
||||
|
||||
let ca_cert_der = pem_to_der(&trust_pem)?;
|
||||
|
||||
Ok(LocalCert {
|
||||
cert_pem,
|
||||
key_pem,
|
||||
ca_cert_der,
|
||||
})
|
||||
}
|
||||
|
||||
fn read_pem_file(path: &Path) -> Result<String, String> {
|
||||
fs::read_to_string(path).map_err(|e| format!("Failed to read {}: {e}", path.display()))
|
||||
}
|
||||
|
||||
fn server_tls_dir() -> Result<PathBuf, String> {
|
||||
Ok(resolve_server_config_base_dir()?.join(TLS_DIR_NAME))
|
||||
}
|
||||
|
||||
fn resolve_tls_asset_paths() -> Result<TlsAssetPaths, String> {
|
||||
let tls_key_path = env::var("CLI_TLS_KEY")
|
||||
.ok()
|
||||
.filter(|value| !value.trim().is_empty())
|
||||
.map(|value| resolve_path_like_server(&value))
|
||||
.transpose()?;
|
||||
let tls_cert_path = env::var("CLI_TLS_CERT")
|
||||
.ok()
|
||||
.filter(|value| !value.trim().is_empty())
|
||||
.map(|value| resolve_path_like_server(&value))
|
||||
.transpose()?;
|
||||
let tls_ca_path = env::var("CLI_TLS_CA")
|
||||
.ok()
|
||||
.filter(|value| !value.trim().is_empty())
|
||||
.map(|value| resolve_path_like_server(&value))
|
||||
.transpose()?;
|
||||
|
||||
match (tls_key_path, tls_cert_path) {
|
||||
(Some(key_path), Some(cert_path)) => {
|
||||
let append_ca_to_cert = tls_ca_path.is_some();
|
||||
let trust_path = tls_ca_path.unwrap_or_else(|| cert_path.clone());
|
||||
Ok(TlsAssetPaths {
|
||||
cert_path,
|
||||
key_path,
|
||||
trust_path,
|
||||
append_ca_to_cert,
|
||||
})
|
||||
}
|
||||
(Some(_), None) | (None, Some(_)) => Err(
|
||||
"CLI_TLS_KEY and CLI_TLS_CERT must both be set when using custom TLS files"
|
||||
.to_string(),
|
||||
),
|
||||
(None, None) => {
|
||||
let tls_dir = server_tls_dir()?;
|
||||
Ok(TlsAssetPaths {
|
||||
cert_path: tls_dir.join(SERVER_CERT_FILE),
|
||||
key_path: tls_dir.join(SERVER_KEY_FILE),
|
||||
trust_path: tls_dir.join(CA_CERT_FILE),
|
||||
append_ca_to_cert: true,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn resolve_server_config_base_dir() -> Result<PathBuf, String> {
|
||||
let raw = env::var("CLI_CONFIG")
|
||||
.ok()
|
||||
.filter(|value| !value.trim().is_empty())
|
||||
.unwrap_or_else(|| DEFAULT_CONFIG_PATH.to_string());
|
||||
let expanded = resolve_path_like_server(&raw)?;
|
||||
let lower = raw.trim().to_lowercase();
|
||||
|
||||
if lower.ends_with(".yaml") || lower.ends_with(".yml") || lower.ends_with(".json") {
|
||||
return expanded
|
||||
.parent()
|
||||
.map(Path::to_path_buf)
|
||||
.ok_or_else(|| format!("Failed to determine config base dir from {}", expanded.display()));
|
||||
}
|
||||
|
||||
Ok(expanded)
|
||||
}
|
||||
|
||||
fn resolve_path_like_server(path: &str) -> Result<PathBuf, String> {
|
||||
if path.starts_with("~/") {
|
||||
let home = dirs::home_dir().or_else(|| env::var("HOME").ok().map(PathBuf::from));
|
||||
let home = home.ok_or_else(|| "Cannot determine home directory".to_string())?;
|
||||
return Ok(home.join(path.trim_start_matches("~/")));
|
||||
}
|
||||
|
||||
let path = PathBuf::from(path);
|
||||
if path.is_absolute() {
|
||||
return Ok(path);
|
||||
}
|
||||
|
||||
let cwd = env::current_dir().map_err(|e| format!("Failed to read current dir: {e}"))?;
|
||||
Ok(cwd.join(path))
|
||||
}
|
||||
|
||||
fn trusted_marker_path() -> Result<PathBuf, String> {
|
||||
let base = dirs::data_local_dir()
|
||||
.ok_or_else(|| "Cannot determine local app data directory".to_string())?;
|
||||
|
||||
#[cfg(windows)]
|
||||
{
|
||||
return Ok(base.join(WINDOWS_APP_USER_MODEL_ID).join(TRUSTED_MARKER));
|
||||
}
|
||||
|
||||
#[cfg(not(windows))]
|
||||
{
|
||||
Ok(base.join("codenomad").join(TRUSTED_MARKER))
|
||||
}
|
||||
}
|
||||
|
||||
fn trusted_marker_value(cert_der: &[u8]) -> String {
|
||||
cert_der.iter().map(|byte| format!("{byte:02x}")).collect()
|
||||
}
|
||||
|
||||
fn has_matching_trusted_marker(cert_der: &[u8]) -> bool {
|
||||
trusted_marker_path()
|
||||
.ok()
|
||||
.and_then(|path| fs::read_to_string(path).ok())
|
||||
.map(|value| value.trim() == trusted_marker_value(cert_der))
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
fn write_trusted_marker(cert_der: &[u8]) -> Result<(), String> {
|
||||
let path = trusted_marker_path()?;
|
||||
if let Some(parent) = path.parent() {
|
||||
fs::create_dir_all(parent)
|
||||
.map_err(|e| format!("Failed to create trust state dir {}: {e}", parent.display()))?;
|
||||
}
|
||||
fs::write(path, trusted_marker_value(cert_der))
|
||||
.map_err(|e| format!("Failed to write trust marker: {e}"))
|
||||
}
|
||||
|
||||
/// Adds the DER-encoded CA certificate to the Windows `CurrentUser\Root` store.
|
||||
/// This will show a one-time Windows security confirmation dialog when needed.
|
||||
#[cfg(windows)]
|
||||
pub fn trust_cert_in_store(cert_der: &[u8]) -> Result<(), String> {
|
||||
use windows_sys::Win32::Security::Cryptography::{
|
||||
CertAddEncodedCertificateToStore, CertCloseStore, CertOpenSystemStoreW,
|
||||
CERT_STORE_ADD_REPLACE_EXISTING, PKCS_7_ASN_ENCODING, X509_ASN_ENCODING,
|
||||
};
|
||||
|
||||
if has_matching_trusted_marker(cert_der) {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let store_name: Vec<u16> = "Root\0".encode_utf16().collect();
|
||||
|
||||
unsafe {
|
||||
let store = CertOpenSystemStoreW(0, store_name.as_ptr());
|
||||
if store.is_null() {
|
||||
return Err("Failed to open CurrentUser\\Root certificate store".into());
|
||||
}
|
||||
|
||||
let encoding = X509_ASN_ENCODING | PKCS_7_ASN_ENCODING;
|
||||
let result = CertAddEncodedCertificateToStore(
|
||||
store,
|
||||
encoding,
|
||||
cert_der.as_ptr(),
|
||||
cert_der.len() as u32,
|
||||
CERT_STORE_ADD_REPLACE_EXISTING,
|
||||
std::ptr::null_mut(),
|
||||
);
|
||||
|
||||
CertCloseStore(store, 0);
|
||||
|
||||
if result == 0 {
|
||||
return Err(
|
||||
"Failed to add certificate to trust store. The user may have declined the security dialog."
|
||||
.into(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
write_trusted_marker(cert_der)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(not(windows))]
|
||||
pub fn trust_cert_in_store(_cert_der: &[u8]) -> Result<(), String> {
|
||||
// Non-Windows platforms use native webview-specific handling instead of OS trust-store writes.
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn pem_to_der(pem: &str) -> Result<Vec<u8>, String> {
|
||||
let mut body = String::new();
|
||||
let mut in_block = false;
|
||||
|
||||
for line in pem.lines() {
|
||||
if line.starts_with("-----BEGIN CERTIFICATE-----") {
|
||||
in_block = true;
|
||||
continue;
|
||||
}
|
||||
if line.starts_with("-----END CERTIFICATE-----") {
|
||||
break;
|
||||
}
|
||||
if in_block {
|
||||
body.push_str(line.trim());
|
||||
}
|
||||
}
|
||||
|
||||
if body.is_empty() {
|
||||
return Err("No certificate found in PEM file".to_string());
|
||||
}
|
||||
|
||||
base64::engine::general_purpose::STANDARD
|
||||
.decode(body)
|
||||
.map_err(|e| format!("Failed to decode certificate PEM: {e}"))
|
||||
}
|
||||
@@ -5,9 +5,13 @@ use serde::{Deserialize, Serialize};
|
||||
use serde_json::json;
|
||||
use std::collections::VecDeque;
|
||||
use std::env;
|
||||
#[cfg(windows)]
|
||||
use std::ffi::c_void;
|
||||
use std::ffi::OsStr;
|
||||
use std::fs;
|
||||
use std::io::{BufRead, BufReader, Read, Write};
|
||||
#[cfg(windows)]
|
||||
use std::mem::{size_of, zeroed};
|
||||
use std::net::TcpStream;
|
||||
#[cfg(unix)]
|
||||
use std::os::unix::process::CommandExt;
|
||||
@@ -19,12 +23,95 @@ use std::thread;
|
||||
use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
|
||||
use tauri::{webview::cookie::Cookie, AppHandle, Emitter, Manager, Url};
|
||||
|
||||
#[cfg(windows)]
|
||||
use std::os::windows::io::AsRawHandle;
|
||||
#[cfg(windows)]
|
||||
use std::os::windows::process::CommandExt;
|
||||
#[cfg(windows)]
|
||||
use windows_sys::Win32::Foundation::{CloseHandle, HANDLE};
|
||||
#[cfg(windows)]
|
||||
use windows_sys::Win32::System::JobObjects::{
|
||||
AssignProcessToJobObject, CreateJobObjectW, JobObjectExtendedLimitInformation,
|
||||
SetInformationJobObject, JOBOBJECT_EXTENDED_LIMIT_INFORMATION,
|
||||
JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE,
|
||||
};
|
||||
|
||||
#[cfg(windows)]
|
||||
const CREATE_NO_WINDOW: u32 = 0x08000000;
|
||||
|
||||
#[cfg(windows)]
|
||||
#[derive(Debug)]
|
||||
struct WindowsJobObject {
|
||||
// The desktop wrapper may observe only a short-lived Node wrapper PID while the real
|
||||
// server and workspace descendants continue running below it. KILL_ON_JOB_CLOSE gives
|
||||
// Tauri an OS-owned handle for the whole subtree instead of relying on a single PID.
|
||||
handle: HANDLE,
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
impl WindowsJobObject {
|
||||
fn create() -> anyhow::Result<Self> {
|
||||
let handle = unsafe { CreateJobObjectW(std::ptr::null_mut(), std::ptr::null()) };
|
||||
if handle.is_null() {
|
||||
return Err(anyhow::anyhow!(
|
||||
"CreateJobObjectW failed: {}",
|
||||
std::io::Error::last_os_error()
|
||||
));
|
||||
}
|
||||
|
||||
let mut info: JOBOBJECT_EXTENDED_LIMIT_INFORMATION = unsafe { zeroed() };
|
||||
info.BasicLimitInformation.LimitFlags = JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE;
|
||||
|
||||
let ok = unsafe {
|
||||
SetInformationJobObject(
|
||||
handle,
|
||||
JobObjectExtendedLimitInformation,
|
||||
&mut info as *mut _ as *mut c_void,
|
||||
size_of::<JOBOBJECT_EXTENDED_LIMIT_INFORMATION>() as u32,
|
||||
)
|
||||
};
|
||||
if ok == 0 {
|
||||
let err = std::io::Error::last_os_error();
|
||||
unsafe {
|
||||
CloseHandle(handle);
|
||||
}
|
||||
return Err(anyhow::anyhow!("SetInformationJobObject failed: {}", err));
|
||||
}
|
||||
|
||||
Ok(Self { handle })
|
||||
}
|
||||
|
||||
fn assign_child(&self, child: &Child) -> anyhow::Result<()> {
|
||||
let process_handle = child.as_raw_handle() as HANDLE;
|
||||
let ok = unsafe { AssignProcessToJobObject(self.handle, process_handle) };
|
||||
if ok == 0 {
|
||||
return Err(anyhow::anyhow!(
|
||||
"AssignProcessToJobObject failed: {}",
|
||||
std::io::Error::last_os_error()
|
||||
));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
impl Drop for WindowsJobObject {
|
||||
fn drop(&mut self) {
|
||||
if !self.handle.is_null() {
|
||||
unsafe {
|
||||
CloseHandle(self.handle);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
unsafe impl Send for WindowsJobObject {}
|
||||
|
||||
#[cfg(windows)]
|
||||
unsafe impl Sync for WindowsJobObject {}
|
||||
|
||||
fn log_line(message: &str) {
|
||||
println!("[tauri-cli] {message}");
|
||||
}
|
||||
@@ -363,6 +450,8 @@ impl Default for CliStatus {
|
||||
pub struct CliProcessManager {
|
||||
status: Arc<Mutex<CliStatus>>,
|
||||
child: Arc<Mutex<Option<Child>>>,
|
||||
#[cfg(windows)]
|
||||
job: Arc<Mutex<Option<WindowsJobObject>>>,
|
||||
ready: Arc<AtomicBool>,
|
||||
bootstrap_token: Arc<Mutex<Option<String>>>,
|
||||
}
|
||||
@@ -372,6 +461,8 @@ impl CliProcessManager {
|
||||
Self {
|
||||
status: Arc::new(Mutex::new(CliStatus::default())),
|
||||
child: Arc::new(Mutex::new(None)),
|
||||
#[cfg(windows)]
|
||||
job: Arc::new(Mutex::new(None)),
|
||||
ready: Arc::new(AtomicBool::new(false)),
|
||||
bootstrap_token: Arc::new(Mutex::new(None)),
|
||||
}
|
||||
@@ -394,6 +485,8 @@ impl CliProcessManager {
|
||||
|
||||
let status_arc = self.status.clone();
|
||||
let child_arc = self.child.clone();
|
||||
#[cfg(windows)]
|
||||
let job_arc = self.job.clone();
|
||||
let ready_flag = self.ready.clone();
|
||||
let token_arc = self.bootstrap_token.clone();
|
||||
thread::spawn(move || {
|
||||
@@ -401,6 +494,8 @@ impl CliProcessManager {
|
||||
app.clone(),
|
||||
status_arc.clone(),
|
||||
child_arc,
|
||||
#[cfg(windows)]
|
||||
job_arc,
|
||||
ready_flag,
|
||||
token_arc,
|
||||
dev,
|
||||
@@ -420,11 +515,12 @@ impl CliProcessManager {
|
||||
}
|
||||
|
||||
pub fn stop(&self) -> anyhow::Result<()> {
|
||||
#[cfg(windows)]
|
||||
let _job = self.job.lock().take();
|
||||
|
||||
let mut child_opt = self.child.lock();
|
||||
if let Some(mut child) = child_opt.take() {
|
||||
log_line(&format!("stopping CLI pid={}", child.id()));
|
||||
#[cfg(windows)]
|
||||
let mut forced_tree_shutdown = false;
|
||||
#[cfg(unix)]
|
||||
unsafe {
|
||||
let pid = child.id() as i32;
|
||||
@@ -446,18 +542,16 @@ impl CliProcessManager {
|
||||
Ok(Some(_)) => break,
|
||||
Ok(None) => {
|
||||
#[cfg(windows)]
|
||||
if !forced_tree_shutdown
|
||||
&& start.elapsed() > Duration::from_millis(CLI_WINDOWS_FORCE_GRACE_MS)
|
||||
{
|
||||
if start.elapsed() > Duration::from_millis(CLI_WINDOWS_FORCE_GRACE_MS) {
|
||||
log_line(&format!(
|
||||
"regular Windows shutdown still running after {}ms; escalating pid={}",
|
||||
CLI_WINDOWS_FORCE_GRACE_MS,
|
||||
child.id()
|
||||
));
|
||||
forced_tree_shutdown = true;
|
||||
if !kill_process_tree_windows(child.id(), true) {
|
||||
let _ = child.kill();
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
if start.elapsed() > Duration::from_secs(CLI_STOP_GRACE_SECS) {
|
||||
@@ -476,11 +570,7 @@ impl CliProcessManager {
|
||||
}
|
||||
#[cfg(windows)]
|
||||
{
|
||||
if !forced_tree_shutdown
|
||||
&& !kill_process_tree_windows(child.id(), true)
|
||||
{
|
||||
let _ = child.kill();
|
||||
} else if forced_tree_shutdown {
|
||||
if !kill_process_tree_windows(child.id(), true) {
|
||||
let _ = child.kill();
|
||||
}
|
||||
}
|
||||
@@ -491,6 +581,9 @@ impl CliProcessManager {
|
||||
Err(_) => break,
|
||||
}
|
||||
}
|
||||
} else {
|
||||
#[cfg(windows)]
|
||||
log_line("tracked CLI process already exited; dropping Windows job object to reap descendants");
|
||||
}
|
||||
|
||||
let mut status = self.status.lock();
|
||||
@@ -511,6 +604,7 @@ impl CliProcessManager {
|
||||
app: AppHandle,
|
||||
status: Arc<Mutex<CliStatus>>,
|
||||
child_holder: Arc<Mutex<Option<Child>>>,
|
||||
#[cfg(windows)] job_holder: Arc<Mutex<Option<WindowsJobObject>>>,
|
||||
ready: Arc<AtomicBool>,
|
||||
bootstrap_token: Arc<Mutex<Option<String>>>,
|
||||
dev: bool,
|
||||
@@ -592,6 +686,22 @@ impl CliProcessManager {
|
||||
|
||||
let pid = child.id();
|
||||
log_line(&format!("spawned pid={pid}"));
|
||||
#[cfg(windows)]
|
||||
match WindowsJobObject::create().and_then(|job| {
|
||||
job.assign_child(&child)?;
|
||||
Ok(job)
|
||||
}) {
|
||||
Ok(job) => {
|
||||
log_line(&format!("attached pid={pid} to Windows job object"));
|
||||
*job_holder.lock() = Some(job);
|
||||
}
|
||||
Err(err) => {
|
||||
log_line(&format!(
|
||||
"failed to attach pid={pid} to Windows job object; falling back to taskkill-only cleanup: {err}"
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
{
|
||||
let mut locked = status.lock();
|
||||
locked.pid = Some(pid);
|
||||
@@ -665,6 +775,8 @@ impl CliProcessManager {
|
||||
let status_clone = status.clone();
|
||||
let ready_clone = ready.clone();
|
||||
let child_holder_clone = child_holder.clone();
|
||||
#[cfg(windows)]
|
||||
let job_holder_clone = job_holder.clone();
|
||||
thread::spawn(move || {
|
||||
let timeout = Duration::from_secs(60);
|
||||
thread::sleep(timeout);
|
||||
@@ -719,6 +831,10 @@ impl CliProcessManager {
|
||||
// Drop the handle after the process exits so other callers
|
||||
// don't attempt to stop/kill a finished process.
|
||||
*guard = None;
|
||||
#[cfg(windows)]
|
||||
{
|
||||
let _ = job_holder_clone.lock().take();
|
||||
}
|
||||
Some(status)
|
||||
}
|
||||
None => None,
|
||||
@@ -776,7 +892,8 @@ impl CliProcessManager {
|
||||
auth_cookie_name: &str,
|
||||
) {
|
||||
let mut buffer = String::new();
|
||||
let local_url_regex = Regex::new(r"^Local\s+Connection\s+URL\s*:\s*(https?://\S+)\s*$").ok();
|
||||
let local_url_regex =
|
||||
Regex::new(r"^Local\s+Connection\s+URL\s*:\s*(https?://\S+)\s*$").ok();
|
||||
let token_prefix = "CODENOMAD_BOOTSTRAP_TOKEN:";
|
||||
|
||||
loop {
|
||||
@@ -818,7 +935,6 @@ impl CliProcessManager {
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
Err(_) => break,
|
||||
@@ -1022,15 +1138,23 @@ fn resolve_tsx(_app: &AppHandle) -> Option<String> {
|
||||
let cwd = std::env::current_dir().ok();
|
||||
let workspace = workspace_root();
|
||||
let mut candidates = vec![
|
||||
cwd.as_ref().map(|p| p.join("node_modules/tsx/dist/cli.mjs")),
|
||||
cwd.as_ref().map(|p| p.join("node_modules/tsx/dist/cli.cjs")),
|
||||
cwd.as_ref()
|
||||
.map(|p| p.join("node_modules/tsx/dist/cli.mjs")),
|
||||
cwd.as_ref()
|
||||
.map(|p| p.join("node_modules/tsx/dist/cli.cjs")),
|
||||
cwd.as_ref().map(|p| p.join("node_modules/tsx/dist/cli.js")),
|
||||
cwd.as_ref().map(|p| p.join("../node_modules/tsx/dist/cli.mjs")),
|
||||
cwd.as_ref().map(|p| p.join("../node_modules/tsx/dist/cli.cjs")),
|
||||
cwd.as_ref().map(|p| p.join("../node_modules/tsx/dist/cli.js")),
|
||||
cwd.as_ref().map(|p| p.join("../../node_modules/tsx/dist/cli.mjs")),
|
||||
cwd.as_ref().map(|p| p.join("../../node_modules/tsx/dist/cli.cjs")),
|
||||
cwd.as_ref().map(|p| p.join("../../node_modules/tsx/dist/cli.js")),
|
||||
cwd.as_ref()
|
||||
.map(|p| p.join("../node_modules/tsx/dist/cli.mjs")),
|
||||
cwd.as_ref()
|
||||
.map(|p| p.join("../node_modules/tsx/dist/cli.cjs")),
|
||||
cwd.as_ref()
|
||||
.map(|p| p.join("../node_modules/tsx/dist/cli.js")),
|
||||
cwd.as_ref()
|
||||
.map(|p| p.join("../../node_modules/tsx/dist/cli.mjs")),
|
||||
cwd.as_ref()
|
||||
.map(|p| p.join("../../node_modules/tsx/dist/cli.cjs")),
|
||||
cwd.as_ref()
|
||||
.map(|p| p.join("../../node_modules/tsx/dist/cli.js")),
|
||||
workspace
|
||||
.as_ref()
|
||||
.map(|p| p.join("node_modules/tsx/dist/cli.mjs")),
|
||||
|
||||
88
packages/tauri-app/src-tauri/src/linux_tls.rs
Normal file
88
packages/tauri-app/src-tauri/src/linux_tls.rs
Normal file
@@ -0,0 +1,88 @@
|
||||
use crate::AppState;
|
||||
use tauri::{AppHandle, Manager, WebviewWindow};
|
||||
use url::Url;
|
||||
use webkit2gtk::{WebContextExt, WebView, WebViewExt};
|
||||
|
||||
pub fn should_bootstrap_tls_navigation(target_url: &Url, skip_tls_verify: bool) -> bool {
|
||||
skip_tls_verify && target_url.scheme() == "https"
|
||||
}
|
||||
|
||||
pub fn ensure_remote_window_tls_handler(
|
||||
window: &WebviewWindow,
|
||||
app_handle: &AppHandle,
|
||||
window_label: &str,
|
||||
) -> Result<(), String> {
|
||||
{
|
||||
let state = app_handle.state::<AppState>();
|
||||
let mut handlers = state
|
||||
.remote_tls_handlers
|
||||
.lock()
|
||||
.map_err(|err| err.to_string())?;
|
||||
if !handlers.insert(window_label.to_string()) {
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
|
||||
let app_handle = app_handle.clone();
|
||||
let window_label = window_label.to_string();
|
||||
window
|
||||
.with_webview(move |platform_webview| {
|
||||
let webview = platform_webview.inner();
|
||||
let app_handle = app_handle.clone();
|
||||
let window_label = window_label.clone();
|
||||
webview.connect_load_failed_with_tls_errors(move |view, failing_uri, certificate, _| {
|
||||
allow_remote_tls_certificate(
|
||||
&app_handle,
|
||||
&window_label,
|
||||
view,
|
||||
failing_uri,
|
||||
certificate,
|
||||
)
|
||||
});
|
||||
})
|
||||
.map_err(|err| err.to_string())
|
||||
}
|
||||
|
||||
fn allow_remote_tls_certificate(
|
||||
app_handle: &AppHandle,
|
||||
window_label: &str,
|
||||
view: &WebView,
|
||||
failing_uri: &str,
|
||||
certificate: &webkit2gtk::gio::TlsCertificate,
|
||||
) -> bool {
|
||||
let Ok(parsed_uri) = Url::parse(failing_uri) else {
|
||||
return false;
|
||||
};
|
||||
let Some(host) = parsed_uri.host_str() else {
|
||||
return false;
|
||||
};
|
||||
|
||||
let state = app_handle.state::<AppState>();
|
||||
let skip_tls_verify = state
|
||||
.remote_skip_tls_verify
|
||||
.lock()
|
||||
.ok()
|
||||
.and_then(|values| values.get(window_label).copied())
|
||||
.unwrap_or(false);
|
||||
if !skip_tls_verify {
|
||||
return false;
|
||||
}
|
||||
|
||||
let expected_origin = state
|
||||
.remote_origins
|
||||
.lock()
|
||||
.ok()
|
||||
.and_then(|origins| origins.get(window_label).cloned());
|
||||
let parsed_origin = parsed_uri.origin().ascii_serialization();
|
||||
if expected_origin.as_deref() != Some(parsed_origin.as_str()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
let Some(context) = view.context() else {
|
||||
return false;
|
||||
};
|
||||
|
||||
context.allow_tls_certificate_for_host(certificate, host);
|
||||
view.load_uri(failing_uri);
|
||||
true
|
||||
}
|
||||
@@ -1,12 +1,16 @@
|
||||
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
|
||||
|
||||
#[allow(dead_code)]
|
||||
mod cert_manager;
|
||||
mod cli_manager;
|
||||
#[cfg(target_os = "linux")]
|
||||
mod linux_tls;
|
||||
|
||||
use cli_manager::{CliProcessManager, CliStatus};
|
||||
use keepawake::KeepAwake;
|
||||
use serde::Deserialize;
|
||||
use serde_json::json;
|
||||
use std::collections::HashMap;
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::Mutex;
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
@@ -45,6 +49,8 @@ pub struct AppState {
|
||||
pub wake_lock: Mutex<Option<KeepAwake>>,
|
||||
pub zoom_level: Mutex<f64>,
|
||||
pub remote_origins: Mutex<HashMap<String, String>>,
|
||||
pub remote_skip_tls_verify: Mutex<HashMap<String, bool>>,
|
||||
pub remote_tls_handlers: Mutex<HashSet<String>>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
@@ -53,6 +59,8 @@ struct RemoteWindowPayload {
|
||||
id: String,
|
||||
name: String,
|
||||
base_url: String,
|
||||
entry_url: Option<String>,
|
||||
#[allow(dead_code)]
|
||||
skip_tls_verify: bool,
|
||||
}
|
||||
|
||||
@@ -119,7 +127,7 @@ fn is_dev_mode() -> bool {
|
||||
|
||||
fn should_allow_internal(url: &Url) -> bool {
|
||||
match url.scheme() {
|
||||
"tauri" | "asset" | "file" => true,
|
||||
"tauri" | "asset" | "file" | "about" => true,
|
||||
// On Windows/WebView2, Tauri serves the app assets from `tauri.localhost`.
|
||||
// This must be treated as an internal origin or the navigation guard will
|
||||
// redirect it to the system browser and the app will appear blank.
|
||||
@@ -167,25 +175,40 @@ fn intercept_navigation<R: Runtime>(webview: &Webview<R>, url: &Url) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn open_remote_window(app: AppHandle, payload: RemoteWindowPayload) -> Result<(), String> {
|
||||
if payload.skip_tls_verify && payload.base_url.starts_with("https://") {
|
||||
return Err(
|
||||
"Tauri cannot bypass self-signed HTTPS certificates automatically yet. Trust the certificate in your OS first, then reconnect, or use the CodeNomad Electron app."
|
||||
.to_string(),
|
||||
);
|
||||
}
|
||||
|
||||
let parsed = Url::parse(&payload.base_url).map_err(|err| err.to_string())?;
|
||||
async fn open_remote_window_impl(
|
||||
app: AppHandle,
|
||||
payload: RemoteWindowPayload,
|
||||
) -> Result<(), String> {
|
||||
let entry_url = payload.entry_url.as_deref().unwrap_or(payload.base_url.as_str());
|
||||
let parsed = Url::parse(entry_url).map_err(|err| err.to_string())?;
|
||||
let label = format!("remote-{}", payload.id);
|
||||
let title = format!(
|
||||
"{} - {}",
|
||||
payload.name,
|
||||
parsed.host_str().unwrap_or(payload.base_url.as_str())
|
||||
Url::parse(&payload.base_url)
|
||||
.ok()
|
||||
.and_then(|url| url.host_str().map(str::to_string))
|
||||
.unwrap_or_else(|| payload.base_url.clone())
|
||||
);
|
||||
|
||||
let window_url = parsed.clone();
|
||||
|
||||
app.state::<AppState>()
|
||||
.remote_origins
|
||||
.lock()
|
||||
.map_err(|err| err.to_string())?
|
||||
.insert(label.clone(), window_url.origin().ascii_serialization());
|
||||
app.state::<AppState>()
|
||||
.remote_skip_tls_verify
|
||||
.lock()
|
||||
.map_err(|err| err.to_string())?
|
||||
.insert(label.clone(), parsed.scheme() == "https");
|
||||
|
||||
if let Some(existing) = app.get_webview_window(&label) {
|
||||
let _ = existing.navigate(parsed.clone());
|
||||
#[cfg(target_os = "linux")]
|
||||
linux_tls::ensure_remote_window_tls_handler(&existing, &app, &label)?;
|
||||
|
||||
let _ = existing.navigate(window_url.clone());
|
||||
let _ = existing.set_title(&title);
|
||||
let _ = existing.show();
|
||||
let _ = existing.unminimize();
|
||||
@@ -193,25 +216,44 @@ fn open_remote_window(app: AppHandle, payload: RemoteWindowPayload) -> Result<()
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
app.state::<AppState>()
|
||||
.remote_origins
|
||||
.lock()
|
||||
.map_err(|err| err.to_string())?
|
||||
.insert(label.clone(), parsed.origin().ascii_serialization());
|
||||
#[cfg(target_os = "linux")]
|
||||
let initial_url = if linux_tls::should_bootstrap_tls_navigation(&window_url, payload.skip_tls_verify)
|
||||
{
|
||||
Url::parse("about:blank").map_err(|err| err.to_string())?
|
||||
} else {
|
||||
window_url.clone()
|
||||
};
|
||||
|
||||
let window =
|
||||
WebviewWindowBuilder::new(&app, label.clone(), WebviewUrl::External(parsed.clone()))
|
||||
.title(title)
|
||||
.inner_size(1400.0, 900.0)
|
||||
.min_inner_size(800.0, 600.0)
|
||||
.build()
|
||||
.map_err(|err| err.to_string())?;
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
let initial_url = window_url.clone();
|
||||
|
||||
let window = WebviewWindowBuilder::new(&app, label.clone(), WebviewUrl::External(initial_url.clone()))
|
||||
.title(title)
|
||||
.inner_size(1400.0, 900.0)
|
||||
.min_inner_size(800.0, 600.0)
|
||||
.build()
|
||||
.map_err(|err| err.to_string())?;
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
linux_tls::ensure_remote_window_tls_handler(&window, &app, &label)?;
|
||||
if initial_url != window_url {
|
||||
let _ = window.navigate(window_url.clone());
|
||||
}
|
||||
}
|
||||
|
||||
let app_handle = app.clone();
|
||||
let label_for_cleanup = label.clone();
|
||||
window.on_window_event(move |event| {
|
||||
if let WindowEvent::Destroyed = event {
|
||||
if let Ok(mut origins) = app_handle.state::<AppState>().remote_origins.lock() {
|
||||
origins.remove(&label);
|
||||
origins.remove(&label_for_cleanup);
|
||||
}
|
||||
if let Ok(mut values) = app_handle.state::<AppState>().remote_skip_tls_verify.lock() {
|
||||
values.remove(&label_for_cleanup);
|
||||
}
|
||||
if let Ok(mut handlers) = app_handle.state::<AppState>().remote_tls_handlers.lock() {
|
||||
handlers.remove(&label_for_cleanup);
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -219,6 +261,29 @@ fn open_remote_window(app: AppHandle, payload: RemoteWindowPayload) -> Result<()
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn open_remote_window(app: AppHandle, payload: RemoteWindowPayload) -> Result<(), String> {
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
{
|
||||
let entry_url = payload.entry_url.as_deref().unwrap_or(payload.base_url.as_str());
|
||||
let parsed = Url::parse(entry_url).map_err(|err| err.to_string())?;
|
||||
if parsed.scheme() == "https" {
|
||||
let local_cert = cert_manager::ensure_local_cert().map_err(|err| {
|
||||
format!(
|
||||
"Failed to load the local HTTPS certificate for the remote proxy window: {err}"
|
||||
)
|
||||
})?;
|
||||
if let Err(err) = cert_manager::trust_cert_in_store(&local_cert.ca_cert_der) {
|
||||
return Err(format!(
|
||||
"Failed to trust the local CodeNomad CA certificate. Accept the certificate installation prompt and try again: {err}"
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
open_remote_window_impl(app, payload).await
|
||||
}
|
||||
|
||||
fn collect_directory_paths(paths: &[std::path::PathBuf]) -> Vec<String> {
|
||||
paths
|
||||
.iter()
|
||||
@@ -346,6 +411,8 @@ fn set_windows_app_user_model_id() {
|
||||
fn set_windows_app_user_model_id() {}
|
||||
|
||||
fn main() {
|
||||
let _ = rustls::crypto::ring::default_provider().install_default();
|
||||
|
||||
let navigation_guard: TauriPlugin<Wry, ()> = PluginBuilder::new("external-link-guard")
|
||||
.on_navigation(|webview, url| intercept_navigation(webview, url))
|
||||
.build();
|
||||
@@ -373,6 +440,8 @@ fn main() {
|
||||
wake_lock: Mutex::new(None),
|
||||
zoom_level: Mutex::new(DEFAULT_ZOOM_LEVEL),
|
||||
remote_origins: Mutex::new(HashMap::new()),
|
||||
remote_skip_tls_verify: Mutex::new(HashMap::new()),
|
||||
remote_tls_handlers: Mutex::new(HashSet::new()),
|
||||
})
|
||||
.setup(|app| {
|
||||
set_windows_app_user_model_id();
|
||||
|
||||
460
packages/tauri-app/src-tauri/src/remote_proxy.rs
Normal file
460
packages/tauri-app/src-tauri/src/remote_proxy.rs
Normal file
@@ -0,0 +1,460 @@
|
||||
use axum::body::Body;
|
||||
use axum::extract::{Request, State};
|
||||
use axum::http::{HeaderMap, HeaderName, HeaderValue, StatusCode, Uri};
|
||||
use axum::response::Response;
|
||||
use axum::routing::any;
|
||||
use axum::Router;
|
||||
use axum_server::tls_rustls::RustlsConfig;
|
||||
use futures_util::TryStreamExt;
|
||||
use rand::RngCore;
|
||||
use reqwest::redirect::Policy;
|
||||
use reqwest::Client;
|
||||
use std::collections::HashSet;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::Arc;
|
||||
use url::Url;
|
||||
|
||||
const PROXY_TOKEN_QUERY: &str = "proxy_token";
|
||||
|
||||
#[derive(Clone)]
|
||||
struct ProxyState {
|
||||
client: Client,
|
||||
target_base_url: Url,
|
||||
local_base_url: Url,
|
||||
session_token: String,
|
||||
session_activated: Arc<AtomicBool>,
|
||||
}
|
||||
|
||||
/// TLS configuration for the local HTTPS proxy.
|
||||
pub struct ProxyTlsConfig {
|
||||
pub cert_pem: String,
|
||||
pub key_pem: String,
|
||||
}
|
||||
|
||||
pub struct RemoteProxyHandle {
|
||||
local_base_url: Url,
|
||||
entry_url: Url,
|
||||
target_base_url: Url,
|
||||
skip_tls_verify: bool,
|
||||
server_handle: axum_server::Handle,
|
||||
}
|
||||
|
||||
impl RemoteProxyHandle {
|
||||
pub fn local_base_url(&self) -> &Url {
|
||||
&self.local_base_url
|
||||
}
|
||||
|
||||
pub fn entry_url(&self) -> &Url {
|
||||
&self.entry_url
|
||||
}
|
||||
|
||||
pub fn matches(&self, target_base_url: &Url, skip_tls_verify: bool) -> bool {
|
||||
self.target_base_url == *target_base_url && self.skip_tls_verify == skip_tls_verify
|
||||
}
|
||||
|
||||
pub fn shutdown(&self) {
|
||||
self.server_handle.shutdown();
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for RemoteProxyHandle {
|
||||
fn drop(&mut self) {
|
||||
self.shutdown();
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn start_remote_proxy(
|
||||
target_base_url: Url,
|
||||
skip_tls_verify: bool,
|
||||
tls_config: Option<ProxyTlsConfig>,
|
||||
) -> Result<RemoteProxyHandle, String> {
|
||||
let client = Client::builder()
|
||||
.redirect(Policy::none())
|
||||
.danger_accept_invalid_certs(skip_tls_verify)
|
||||
.build()
|
||||
.map_err(|err| err.to_string())?;
|
||||
|
||||
// Pre-bind a std TcpListener on port 0 to discover the actual port
|
||||
let std_listener = std::net::TcpListener::bind("127.0.0.1:0")
|
||||
.map_err(|err| err.to_string())?;
|
||||
let address = std_listener.local_addr().map_err(|err| err.to_string())?;
|
||||
|
||||
let scheme = if tls_config.is_some() { "https" } else { "http" };
|
||||
let local_base_url =
|
||||
Url::parse(&format!("{scheme}://{address}")).map_err(|err| err.to_string())?;
|
||||
let session_token = generate_session_token();
|
||||
let mut entry_url = local_base_url.clone();
|
||||
entry_url.set_path(target_base_url.path());
|
||||
entry_url.set_query(Some(&format!("{PROXY_TOKEN_QUERY}={session_token}")));
|
||||
|
||||
let state = Arc::new(ProxyState {
|
||||
client,
|
||||
target_base_url: target_base_url.clone(),
|
||||
local_base_url: local_base_url.clone(),
|
||||
session_token,
|
||||
session_activated: Arc::new(AtomicBool::new(false)),
|
||||
});
|
||||
|
||||
let app = Router::new()
|
||||
.route("/*path", any(proxy_request))
|
||||
.route("/", any(proxy_request))
|
||||
.with_state(state);
|
||||
|
||||
let server_handle = axum_server::Handle::new();
|
||||
let handle_clone = server_handle.clone();
|
||||
|
||||
if let Some(tls) = tls_config {
|
||||
let rustls_config =
|
||||
RustlsConfig::from_pem(tls.cert_pem.into_bytes(), tls.key_pem.into_bytes())
|
||||
.await
|
||||
.map_err(|err| format!("Failed to build RustlsConfig: {err}"))?;
|
||||
|
||||
tauri::async_runtime::spawn(async move {
|
||||
let server = axum_server::from_tcp_rustls(std_listener, rustls_config)
|
||||
.handle(handle_clone)
|
||||
.serve(app.into_make_service());
|
||||
|
||||
if let Err(err) = server.await {
|
||||
eprintln!("[tauri] remote proxy (HTTPS) stopped with error: {err}");
|
||||
}
|
||||
});
|
||||
} else {
|
||||
tauri::async_runtime::spawn(async move {
|
||||
let server = axum_server::from_tcp(std_listener)
|
||||
.handle(handle_clone)
|
||||
.serve(app.into_make_service());
|
||||
|
||||
if let Err(err) = server.await {
|
||||
eprintln!("[tauri] remote proxy (HTTP) stopped with error: {err}");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
Ok(RemoteProxyHandle {
|
||||
local_base_url,
|
||||
entry_url,
|
||||
target_base_url,
|
||||
skip_tls_verify,
|
||||
server_handle,
|
||||
})
|
||||
}
|
||||
|
||||
async fn proxy_request(
|
||||
State(state): State<Arc<ProxyState>>,
|
||||
request: Request,
|
||||
) -> Result<Response<Body>, StatusCode> {
|
||||
if !state.session_activated.load(Ordering::SeqCst) {
|
||||
if request_bootstraps_session(&request, &state.session_token) {
|
||||
state.session_activated.store(true, Ordering::SeqCst);
|
||||
return Ok(build_bootstrap_response(request.uri())?);
|
||||
}
|
||||
return Err(StatusCode::FORBIDDEN);
|
||||
}
|
||||
|
||||
let upstream_url = build_upstream_url(&state.target_base_url, request.uri())
|
||||
.map_err(|_| StatusCode::BAD_REQUEST)?;
|
||||
|
||||
let mut builder = state
|
||||
.client
|
||||
.request(request.method().clone(), upstream_url.clone());
|
||||
builder = builder.headers(filter_request_headers(
|
||||
request.headers(),
|
||||
&state.target_base_url,
|
||||
)?);
|
||||
|
||||
let body = axum::body::to_bytes(request.into_body(), usize::MAX)
|
||||
.await
|
||||
.map_err(|_| StatusCode::BAD_REQUEST)?;
|
||||
if !body.is_empty() {
|
||||
builder = builder.body(body);
|
||||
}
|
||||
|
||||
let upstream = builder.send().await.map_err(map_upstream_error)?;
|
||||
let status = upstream.status();
|
||||
let headers = rewrite_response_headers(
|
||||
upstream.headers(),
|
||||
&state.target_base_url,
|
||||
&state.local_base_url,
|
||||
)?;
|
||||
let stream = upstream
|
||||
.bytes_stream()
|
||||
.map_err(|err| std::io::Error::other(err.to_string()));
|
||||
|
||||
let mut response = Response::new(Body::from_stream(stream));
|
||||
*response.status_mut() = status;
|
||||
*response.headers_mut() = headers;
|
||||
Ok(response)
|
||||
}
|
||||
|
||||
fn build_upstream_url(base_url: &Url, uri: &Uri) -> Result<Url, url::ParseError> {
|
||||
let mut url = base_url.clone();
|
||||
url.set_path(&rewrite_request_path(base_url, uri.path()));
|
||||
url.set_query(strip_proxy_token_query(uri.query()).as_deref());
|
||||
Ok(url)
|
||||
}
|
||||
|
||||
fn rewrite_request_path(base_url: &Url, request_path: &str) -> String {
|
||||
let base_path = normalized_base_path(base_url);
|
||||
if base_path == "/" {
|
||||
return request_path.to_string();
|
||||
}
|
||||
|
||||
if request_path == "/" {
|
||||
return base_path.to_string();
|
||||
}
|
||||
|
||||
if path_has_base_prefix(base_path, request_path) {
|
||||
return request_path.to_string();
|
||||
}
|
||||
|
||||
format!("{base_path}{request_path}")
|
||||
}
|
||||
|
||||
fn normalized_base_path(base_url: &Url) -> &str {
|
||||
let path = base_url.path();
|
||||
if path.is_empty() { "/" } else { path }
|
||||
}
|
||||
|
||||
fn path_has_base_prefix(base_path: &str, request_path: &str) -> bool {
|
||||
request_path == base_path
|
||||
|| request_path
|
||||
.strip_prefix(base_path)
|
||||
.is_some_and(|suffix| suffix.starts_with('/'))
|
||||
}
|
||||
|
||||
fn generate_session_token() -> String {
|
||||
let mut bytes = [0_u8; 16];
|
||||
rand::thread_rng().fill_bytes(&mut bytes);
|
||||
bytes.iter().map(|byte| format!("{byte:02x}")).collect()
|
||||
}
|
||||
|
||||
fn request_bootstraps_session(request: &Request, session_token: &str) -> bool {
|
||||
request.uri().query().is_some_and(|query| {
|
||||
url::form_urlencoded::parse(query.as_bytes())
|
||||
.any(|(name, value)| name == PROXY_TOKEN_QUERY && value == session_token)
|
||||
})
|
||||
}
|
||||
|
||||
fn build_bootstrap_response(uri: &Uri) -> Result<Response<Body>, StatusCode> {
|
||||
let redirect_target = sanitized_request_target(uri);
|
||||
|
||||
Response::builder()
|
||||
.status(StatusCode::FOUND)
|
||||
.header(axum::http::header::LOCATION, redirect_target)
|
||||
.body(Body::empty())
|
||||
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)
|
||||
}
|
||||
|
||||
fn sanitized_request_target(uri: &Uri) -> String {
|
||||
let path = if uri.path().is_empty() { "/" } else { uri.path() };
|
||||
match strip_proxy_token_query(uri.query()) {
|
||||
Some(query) if !query.is_empty() => format!("{path}?{query}"),
|
||||
_ => path.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
fn strip_proxy_token_query(query: Option<&str>) -> Option<String> {
|
||||
let query = query?;
|
||||
let filtered: Vec<(std::borrow::Cow<'_, str>, std::borrow::Cow<'_, str>)> =
|
||||
url::form_urlencoded::parse(query.as_bytes())
|
||||
.filter(|(name, _)| name != PROXY_TOKEN_QUERY)
|
||||
.collect();
|
||||
|
||||
if filtered.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
Some(
|
||||
url::form_urlencoded::Serializer::new(String::new())
|
||||
.extend_pairs(filtered)
|
||||
.finish(),
|
||||
)
|
||||
}
|
||||
|
||||
fn filter_request_headers(
|
||||
headers: &HeaderMap,
|
||||
target_base_url: &Url,
|
||||
) -> Result<HeaderMap, StatusCode> {
|
||||
let mut forwarded = HeaderMap::new();
|
||||
for (name, value) in headers {
|
||||
if is_hop_by_hop_header(name) || *name == axum::http::header::HOST {
|
||||
continue;
|
||||
}
|
||||
forwarded.append(name.clone(), value.clone());
|
||||
}
|
||||
|
||||
let host = target_base_url.host_str().ok_or(StatusCode::BAD_REQUEST)?;
|
||||
let host_value = match target_base_url.port() {
|
||||
Some(port) => format!("{host}:{port}"),
|
||||
None => host.to_string(),
|
||||
};
|
||||
forwarded.insert(
|
||||
axum::http::header::HOST,
|
||||
HeaderValue::from_str(&host_value).map_err(|_| StatusCode::BAD_REQUEST)?,
|
||||
);
|
||||
|
||||
let target_origin = target_base_url.origin().ascii_serialization();
|
||||
if let Ok(origin) = HeaderValue::from_str(&target_origin) {
|
||||
forwarded.insert(axum::http::header::ORIGIN, origin);
|
||||
}
|
||||
|
||||
if let Some(referer) = rewrite_referer_header(headers, target_base_url) {
|
||||
forwarded.insert(
|
||||
axum::http::header::REFERER,
|
||||
HeaderValue::from_str(&referer).map_err(|_| StatusCode::BAD_REQUEST)?,
|
||||
);
|
||||
}
|
||||
|
||||
Ok(forwarded)
|
||||
}
|
||||
|
||||
fn rewrite_referer_header(headers: &HeaderMap, target_base_url: &Url) -> Option<String> {
|
||||
let referer = headers.get(axum::http::header::REFERER)?.to_str().ok()?;
|
||||
let parsed = Url::parse(referer).ok()?;
|
||||
|
||||
let mut rewritten = target_base_url.clone();
|
||||
rewritten.set_path(&rewrite_request_path(target_base_url, parsed.path()));
|
||||
rewritten.set_query(parsed.query());
|
||||
rewritten.set_fragment(parsed.fragment());
|
||||
Some(rewritten.to_string())
|
||||
}
|
||||
|
||||
fn rewrite_response_headers(
|
||||
headers: &HeaderMap,
|
||||
target_base_url: &Url,
|
||||
local_base_url: &Url,
|
||||
) -> Result<HeaderMap, StatusCode> {
|
||||
let mut rewritten = HeaderMap::new();
|
||||
for (name, value) in headers {
|
||||
if is_hop_by_hop_header(name) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if *name == axum::http::header::LOCATION {
|
||||
if let Ok(location) = value.to_str() {
|
||||
let next = rewrite_location(location, target_base_url, local_base_url);
|
||||
rewritten.append(
|
||||
name.clone(),
|
||||
HeaderValue::from_str(&next).map_err(|_| StatusCode::BAD_GATEWAY)?,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
if *name == axum::http::header::SET_COOKIE {
|
||||
if let Ok(cookie) = value.to_str() {
|
||||
let next = rewrite_set_cookie(cookie);
|
||||
rewritten.append(
|
||||
name.clone(),
|
||||
HeaderValue::from_str(&next).map_err(|_| StatusCode::BAD_GATEWAY)?,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
rewritten.append(name.clone(), value.clone());
|
||||
}
|
||||
Ok(rewritten)
|
||||
}
|
||||
|
||||
fn rewrite_set_cookie(cookie: &str) -> String {
|
||||
cookie
|
||||
.split(';')
|
||||
.map(str::trim)
|
||||
.filter(|part| !part.get(..7).is_some_and(|prefix| prefix.eq_ignore_ascii_case("Domain=")))
|
||||
.collect::<Vec<_>>()
|
||||
.join("; ")
|
||||
}
|
||||
|
||||
fn rewrite_location(location: &str, target_base_url: &Url, local_base_url: &Url) -> String {
|
||||
let Ok(parsed) = target_base_url.join(location) else {
|
||||
return location.to_string();
|
||||
};
|
||||
|
||||
if parsed.origin() != target_base_url.origin() {
|
||||
return location.to_string();
|
||||
}
|
||||
|
||||
let mut rewritten = local_base_url.clone();
|
||||
rewritten.set_path(parsed.path());
|
||||
rewritten.set_query(parsed.query());
|
||||
rewritten.set_fragment(parsed.fragment());
|
||||
rewritten.to_string()
|
||||
}
|
||||
|
||||
fn map_upstream_error(error: reqwest::Error) -> StatusCode {
|
||||
if error.is_timeout() {
|
||||
StatusCode::GATEWAY_TIMEOUT
|
||||
} else if error.is_connect() {
|
||||
StatusCode::BAD_GATEWAY
|
||||
} else {
|
||||
StatusCode::INTERNAL_SERVER_ERROR
|
||||
}
|
||||
}
|
||||
|
||||
fn is_hop_by_hop_header(name: &HeaderName) -> bool {
|
||||
static HOP_BY_HOP: std::sync::OnceLock<HashSet<&'static str>> = std::sync::OnceLock::new();
|
||||
HOP_BY_HOP
|
||||
.get_or_init(|| {
|
||||
HashSet::from([
|
||||
"connection",
|
||||
"keep-alive",
|
||||
"proxy-authenticate",
|
||||
"proxy-authorization",
|
||||
"te",
|
||||
"trailer",
|
||||
"transfer-encoding",
|
||||
"upgrade",
|
||||
])
|
||||
})
|
||||
.contains(name.as_str())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn build_upstream_url_prefixes_root_relative_requests_under_base_path() {
|
||||
let base = Url::parse("https://example.com/app").unwrap();
|
||||
let uri = "/api/auth/status?foo=bar".parse::<Uri>().unwrap();
|
||||
|
||||
let upstream = build_upstream_url(&base, &uri).unwrap();
|
||||
|
||||
assert_eq!(upstream.as_str(), "https://example.com/app/api/auth/status?foo=bar");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_upstream_url_keeps_requests_already_under_base_path() {
|
||||
let base = Url::parse("https://example.com/app").unwrap();
|
||||
let uri = "/app/api/auth/status?foo=bar".parse::<Uri>().unwrap();
|
||||
|
||||
let upstream = build_upstream_url(&base, &uri).unwrap();
|
||||
|
||||
assert_eq!(upstream.as_str(), "https://example.com/app/api/auth/status?foo=bar");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_upstream_url_maps_root_to_base_path() {
|
||||
let base = Url::parse("https://example.com/app").unwrap();
|
||||
let uri = "/".parse::<Uri>().unwrap();
|
||||
|
||||
let upstream = build_upstream_url(&base, &uri).unwrap();
|
||||
|
||||
assert_eq!(upstream.as_str(), "https://example.com/app");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rewrite_referer_header_prefixes_root_relative_path_under_base_path() {
|
||||
let target = Url::parse("https://example.com/app").unwrap();
|
||||
let mut headers = HeaderMap::new();
|
||||
headers.insert(
|
||||
axum::http::header::REFERER,
|
||||
HeaderValue::from_static("https://127.0.0.1:3000/api/auth/status?foo=bar"),
|
||||
);
|
||||
|
||||
let referer = rewrite_referer_header(&headers, &target).unwrap();
|
||||
|
||||
assert_eq!(referer, "https://example.com/app/api/auth/status?foo=bar");
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "CodeNomad",
|
||||
"version": "0.13.3",
|
||||
"version": "0.14.0",
|
||||
"identifier": "ai.neuralnomads.codenomad.client",
|
||||
"build": {
|
||||
"beforeDevCommand": "npm run dev:bootstrap",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@codenomad/ui",
|
||||
"version": "0.13.3",
|
||||
"version": "0.14.0",
|
||||
"private": true,
|
||||
"license": "MIT",
|
||||
"type": "module",
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { createEffect, createMemo, createSignal, onCleanup, onMount } from "solid-js"
|
||||
import { Show, createEffect, createMemo, createSignal, onCleanup, onMount } from "solid-js"
|
||||
import { loadMonaco } from "../../lib/monaco/setup"
|
||||
import { getOrCreateTextModel } from "../../lib/monaco/model-cache"
|
||||
import { inferMonacoLanguageId } from "../../lib/monaco/language"
|
||||
@@ -15,6 +15,8 @@ interface MonacoDiffViewerProps {
|
||||
viewMode?: "split" | "unified"
|
||||
contextMode?: "expanded" | "collapsed"
|
||||
wordWrap?: "on" | "off"
|
||||
onRequestInsertContext?: (selection: { startLine: number; endLine: number }) => void
|
||||
insertContextLabel?: string
|
||||
}
|
||||
|
||||
export function MonacoDiffViewer(props: MonacoDiffViewerProps) {
|
||||
@@ -24,6 +26,10 @@ export function MonacoDiffViewer(props: MonacoDiffViewerProps) {
|
||||
let diffEditor: any = null
|
||||
let monaco: any = null
|
||||
const [ready, setReady] = createSignal(false)
|
||||
const [hoveredLine, setHoveredLine] = createSignal<number | null>(null)
|
||||
const [selectedRange, setSelectedRange] = createSignal<{ startLine: number; endLine: number } | null>(null)
|
||||
const [widgetHovered, setWidgetHovered] = createSignal(false)
|
||||
const [widgetPosition, setWidgetPosition] = createSignal<{ top: number; left: number } | null>(null)
|
||||
|
||||
const resolvedContent = createMemo(() => {
|
||||
if (props.patch !== undefined && props.patch !== null) {
|
||||
@@ -49,6 +55,52 @@ export function MonacoDiffViewer(props: MonacoDiffViewerProps) {
|
||||
diffEditor = null
|
||||
}
|
||||
|
||||
const getModifiedEditor = () => diffEditor?.getModifiedEditor?.() ?? null
|
||||
|
||||
const getActiveInsertRange = () => {
|
||||
const selection = selectedRange()
|
||||
if (selection) return selection
|
||||
if (widgetHovered() && hoveredLine()) {
|
||||
return { startLine: hoveredLine() as number, endLine: hoveredLine() as number }
|
||||
}
|
||||
const line = hoveredLine()
|
||||
if (!line) return null
|
||||
return { startLine: line, endLine: line }
|
||||
}
|
||||
|
||||
const layoutInsertWidget = () => {
|
||||
const modifiedEditor = getModifiedEditor()
|
||||
const container = host
|
||||
if (!modifiedEditor || !container) return
|
||||
const activeRange = getActiveInsertRange()
|
||||
if (!activeRange) {
|
||||
setWidgetPosition(null)
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const modifiedDom = modifiedEditor.getDomNode?.() as HTMLElement | null
|
||||
if (!modifiedDom) {
|
||||
setWidgetPosition(null)
|
||||
return
|
||||
}
|
||||
|
||||
const margin = modifiedDom.querySelector<HTMLElement>(".margin")
|
||||
const scrollable = modifiedDom.querySelector<HTMLElement>(".monaco-scrollable-element.editor-scrollable")
|
||||
const lineTop = modifiedEditor.getTopForLineNumber?.(activeRange.startLine) ?? 0
|
||||
const scrollTop = modifiedEditor.getScrollTop?.() ?? 0
|
||||
const lineHeight = Number(modifiedEditor.getOption?.(monaco.editor.EditorOption.lineHeight) ?? 18)
|
||||
const modifiedRect = modifiedDom.getBoundingClientRect()
|
||||
const containerRect = container.getBoundingClientRect()
|
||||
const seamLeft = modifiedRect.left - containerRect.left + (margin?.offsetWidth ?? scrollable?.offsetLeft ?? 0)
|
||||
const centerTop = modifiedRect.top - containerRect.top + (lineTop - scrollTop) + lineHeight / 2
|
||||
|
||||
setWidgetPosition({ top: centerTop, left: seamLeft })
|
||||
} catch {
|
||||
setWidgetPosition(null)
|
||||
}
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
let cancelled = false
|
||||
void (async () => {
|
||||
@@ -68,7 +120,7 @@ export function MonacoDiffViewer(props: MonacoDiffViewerProps) {
|
||||
renderWhitespace: "selection",
|
||||
fontSize: 13,
|
||||
wordWrap: props.wordWrap === "on" ? "on" : "off",
|
||||
glyphMargin: false,
|
||||
glyphMargin: true,
|
||||
folding: false,
|
||||
// Keep enough gutter space so unified diffs don't overlap `+`/`-` markers.
|
||||
lineNumbersMinChars: 4,
|
||||
@@ -81,6 +133,8 @@ export function MonacoDiffViewer(props: MonacoDiffViewerProps) {
|
||||
})
|
||||
|
||||
setReady(true)
|
||||
|
||||
layoutInsertWidget()
|
||||
})()
|
||||
|
||||
onCleanup(() => {
|
||||
@@ -95,6 +149,74 @@ export function MonacoDiffViewer(props: MonacoDiffViewerProps) {
|
||||
monaco.editor.setTheme(isDark() ? "vs-dark" : "vs")
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
if (!ready() || !monaco || !diffEditor) return
|
||||
const modifiedEditor = diffEditor.getModifiedEditor?.()
|
||||
if (!modifiedEditor?.onDidChangeCursorSelection) return
|
||||
|
||||
const disposable = modifiedEditor.onDidChangeCursorSelection((event: any) => {
|
||||
const selection = event?.selection
|
||||
if (!selection || selection.isEmpty?.()) {
|
||||
setSelectedRange(null)
|
||||
layoutInsertWidget()
|
||||
return
|
||||
}
|
||||
setSelectedRange({
|
||||
startLine: Math.min(selection.startLineNumber, selection.endLineNumber),
|
||||
endLine: Math.max(selection.startLineNumber, selection.endLineNumber),
|
||||
})
|
||||
layoutInsertWidget()
|
||||
})
|
||||
|
||||
onCleanup(() => {
|
||||
try {
|
||||
disposable?.dispose?.()
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
if (!ready() || !monaco || !diffEditor) return
|
||||
const modifiedEditor = getModifiedEditor()
|
||||
if (!modifiedEditor?.onMouseMove || !modifiedEditor?.onMouseLeave || !modifiedEditor?.onMouseDown) return
|
||||
|
||||
const moveDisposable = modifiedEditor.onMouseMove((event: any) => {
|
||||
const lineNumber = event?.target?.position?.lineNumber
|
||||
setHoveredLine(typeof lineNumber === "number" ? lineNumber : null)
|
||||
layoutInsertWidget()
|
||||
})
|
||||
|
||||
const leaveDisposable = modifiedEditor.onMouseLeave(() => {
|
||||
if (!widgetHovered()) {
|
||||
setHoveredLine(null)
|
||||
}
|
||||
layoutInsertWidget()
|
||||
})
|
||||
|
||||
const scrollDisposable = modifiedEditor.onDidScrollChange?.(() => {
|
||||
layoutInsertWidget()
|
||||
})
|
||||
|
||||
onCleanup(() => {
|
||||
try {
|
||||
moveDisposable?.dispose?.()
|
||||
leaveDisposable?.dispose?.()
|
||||
scrollDisposable?.dispose?.()
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
if (!ready() || !monaco || !diffEditor) return
|
||||
const activeRange = getActiveInsertRange()
|
||||
if (!activeRange) setWidgetPosition(null)
|
||||
layoutInsertWidget()
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
if (!ready() || !monaco || !diffEditor) return
|
||||
const viewMode = props.viewMode === "unified" ? "unified" : "split"
|
||||
@@ -145,5 +267,46 @@ export function MonacoDiffViewer(props: MonacoDiffViewerProps) {
|
||||
})
|
||||
})
|
||||
|
||||
return <div class="monaco-viewer" ref={host} />
|
||||
return (
|
||||
<div class="monaco-viewer" ref={host}>
|
||||
<div class="git-change-context-overlay">
|
||||
<Show when={widgetPosition()}>
|
||||
{(position: () => { top: number; left: number }) => (
|
||||
<div
|
||||
class="git-change-context-widget-host"
|
||||
style={{ top: `${position().top}px`, left: `${position().left}px` }}
|
||||
onMouseEnter={() => {
|
||||
setWidgetHovered(true)
|
||||
layoutInsertWidget()
|
||||
}}
|
||||
onMouseLeave={() => {
|
||||
setWidgetHovered(false)
|
||||
layoutInsertWidget()
|
||||
}}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="git-change-context-widget"
|
||||
aria-label={props.insertContextLabel ?? "Add git change context to prompt"}
|
||||
title={props.insertContextLabel ?? "Add git change context to prompt"}
|
||||
onMouseDown={(event) => {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
}}
|
||||
onClick={(event) => {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
const activeRange = getActiveInsertRange()
|
||||
if (!activeRange) return
|
||||
props.onRequestInsertContext?.(activeRange)
|
||||
}}
|
||||
>
|
||||
+
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@ import { showAlertDialog } from "../stores/alerts"
|
||||
import { openSettings, settingsOpen } from "../stores/settings-screen"
|
||||
import { openExternalUrl } from "../lib/external-url"
|
||||
import { serverApi } from "../lib/api-client"
|
||||
import { runtimeEnv } from "../lib/runtime-env"
|
||||
import { openRemoteServerWindow } from "../lib/native/remote-window"
|
||||
|
||||
const codeNomadLogo = new URL("../images/CodeNomad-Icon.png", import.meta.url).href
|
||||
@@ -332,7 +333,15 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
|
||||
})
|
||||
|
||||
if (openWindow) {
|
||||
await openRemoteServerWindow(profile)
|
||||
const windowUrl =
|
||||
runtimeEnv.host === "tauri"
|
||||
? (await serverApi.createRemoteProxySession({
|
||||
baseUrl: profile.baseUrl,
|
||||
skipTlsVerify: profile.skipTlsVerify,
|
||||
})).windowUrl
|
||||
: undefined
|
||||
|
||||
await openRemoteServerWindow(profile, windowUrl)
|
||||
await markRemoteServerConnected(profile.id)
|
||||
}
|
||||
|
||||
|
||||
@@ -43,6 +43,7 @@ import RightPanel from "./shell/right-panel/RightPanel"
|
||||
import { useDrawerChrome } from "./shell/useDrawerChrome"
|
||||
import { getRetrySeconds, getSessionRetry, getSessionStatus } from "../../stores/session-status"
|
||||
import { Maximize2, ShieldAlert } from "lucide-solid"
|
||||
import type { PromptInputApi } from "../prompt-input/types"
|
||||
|
||||
import type { LayoutMode } from "./shell/types"
|
||||
import {
|
||||
@@ -105,6 +106,7 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
|
||||
const [showBackgroundOutput, setShowBackgroundOutput] = createSignal(false)
|
||||
const [permissionModalOpen, setPermissionModalOpen] = createSignal(false)
|
||||
const [now, setNow] = createSignal(Date.now())
|
||||
const [sessionPromptApis, setSessionPromptApis] = createSignal<Record<string, PromptInputApi | null>>({})
|
||||
|
||||
// Worktree selector manages its own dialogs.
|
||||
const [showSessionSearch, setShowSessionSearch] = createSignal(false)
|
||||
@@ -268,6 +270,19 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
|
||||
|
||||
const permissionQueue = createMemo(() => getPermissionQueue(props.instance.id))
|
||||
|
||||
const activePromptInputApi = createMemo(() => {
|
||||
const sessionId = activeSessionIdForInstance()
|
||||
if (!sessionId || sessionId === "info") return null
|
||||
return sessionPromptApis()[sessionId] ?? null
|
||||
})
|
||||
|
||||
const registerSessionPromptApi = (sessionId: string, api: PromptInputApi | null) => {
|
||||
setSessionPromptApis((current) => ({
|
||||
...current,
|
||||
[sessionId]: api,
|
||||
}))
|
||||
}
|
||||
|
||||
createEffect(() => {
|
||||
getPermissionAutoAcceptInFlightVersion()
|
||||
|
||||
@@ -594,6 +609,7 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
|
||||
onCloseRightDrawer={closeRightDrawer}
|
||||
onPinRightDrawer={pinRightDrawer}
|
||||
onUnpinRightDrawer={unpinRightDrawer}
|
||||
promptInputApi={activePromptInputApi}
|
||||
setContentEl={setRightDrawerContentEl}
|
||||
/>
|
||||
</Box>
|
||||
@@ -656,6 +672,7 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
|
||||
onCloseRightDrawer={closeRightDrawer}
|
||||
onPinRightDrawer={pinRightDrawer}
|
||||
onUnpinRightDrawer={unpinRightDrawer}
|
||||
promptInputApi={activePromptInputApi}
|
||||
setContentEl={setRightDrawerContentEl}
|
||||
/>
|
||||
</Drawer>
|
||||
@@ -892,6 +909,7 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
|
||||
escapeInDebounce={props.escapeInDebounce}
|
||||
isPhoneLayout={isPhoneLayout()}
|
||||
compactPromptLayout={compactPromptLayout()}
|
||||
registerSessionPromptApi={registerSessionPromptApi}
|
||||
showSidebarToggle={showEmbeddedSidebarToggle()}
|
||||
onSidebarToggle={() => setLeftOpen(true)}
|
||||
forceCompactStatusLayout={showEmbeddedSidebarToggle()}
|
||||
|
||||
@@ -10,7 +10,7 @@ import {
|
||||
type Component,
|
||||
} from "solid-js"
|
||||
import type { ToolState } from "@opencode-ai/sdk/v2"
|
||||
import type { FileContent, FileNode, File as GitFileStatus } from "@opencode-ai/sdk/v2/client"
|
||||
import type { FileContent, FileNode } from "@opencode-ai/sdk/v2/client"
|
||||
import IconButton from "@suid/material/IconButton"
|
||||
import MenuOpenIcon from "@suid/icons-material/MenuOpen"
|
||||
import PushPinIcon from "@suid/icons-material/PushPin"
|
||||
@@ -19,16 +19,23 @@ import PushPinOutlinedIcon from "@suid/icons-material/PushPinOutlined"
|
||||
import type { Instance } from "../../../../types/instance"
|
||||
import type { BackgroundProcess } from "../../../../../../server/src/api-types"
|
||||
import type { Session } from "../../../../types/session"
|
||||
import type { PromptInputApi } from "../../../prompt-input/types"
|
||||
import type { DrawerViewState } from "../types"
|
||||
import type { DiffContextMode, DiffViewMode, DiffWordWrapMode, RightPanelTab } from "./types"
|
||||
|
||||
import { getDefaultWorktreeSlug, getOrCreateWorktreeClient, getWorktreeSlugForSession } from "../../../../stores/worktrees"
|
||||
import {
|
||||
getDefaultWorktreeSlug,
|
||||
getGitRepoStatus,
|
||||
getOrCreateWorktreeClient,
|
||||
getWorktreeSlugForSession,
|
||||
getWorktrees,
|
||||
} from "../../../../stores/worktrees"
|
||||
import { requestData } from "../../../../lib/opencode-api"
|
||||
import { serverApi } from "../../../../lib/api-client"
|
||||
import { showConfirmDialog } from "../../../../stores/alerts"
|
||||
import { showToastNotification } from "../../../../lib/notifications"
|
||||
import { buildUnifiedDiffFromSdkPatch, tryReverseApplyUnifiedDiff } from "../../../../lib/unified-diff-reverse"
|
||||
import { useGlobalPointerDrag } from "../useGlobalPointerDrag"
|
||||
import { useGitChanges } from "./useGitChanges"
|
||||
import {
|
||||
RIGHT_PANEL_CHANGES_DIFF_CONTEXT_MODE_KEY,
|
||||
RIGHT_PANEL_CHANGES_DIFF_VIEW_MODE_KEY,
|
||||
@@ -41,7 +48,11 @@ import {
|
||||
RIGHT_PANEL_FILES_SPLIT_WIDTH_KEY,
|
||||
RIGHT_PANEL_GIT_CHANGES_LIST_OPEN_NONPHONE_KEY,
|
||||
RIGHT_PANEL_GIT_CHANGES_LIST_OPEN_PHONE_KEY,
|
||||
RIGHT_PANEL_GIT_CHANGES_STAGED_OPEN_NONPHONE_KEY,
|
||||
RIGHT_PANEL_GIT_CHANGES_STAGED_OPEN_PHONE_KEY,
|
||||
RIGHT_PANEL_GIT_CHANGES_SPLIT_WIDTH_KEY,
|
||||
RIGHT_PANEL_GIT_CHANGES_UNSTAGED_OPEN_NONPHONE_KEY,
|
||||
RIGHT_PANEL_GIT_CHANGES_UNSTAGED_OPEN_PHONE_KEY,
|
||||
RIGHT_PANEL_TAB_STORAGE_KEY,
|
||||
readStoredBool,
|
||||
readStoredEnum,
|
||||
@@ -82,6 +93,7 @@ interface RightPanelProps {
|
||||
onCloseRightDrawer: () => void
|
||||
onPinRightDrawer: () => void
|
||||
onUnpinRightDrawer: () => void
|
||||
promptInputApi: Accessor<PromptInputApi | null>
|
||||
|
||||
setContentEl: (el: HTMLElement | null) => void
|
||||
}
|
||||
@@ -133,6 +145,8 @@ const RightPanel: Component<RightPanelProps> = (props) => {
|
||||
const [changesListTouched, setChangesListTouched] = createSignal(false)
|
||||
const [gitChangesListOpen, setGitChangesListOpen] = createSignal(true)
|
||||
const [gitChangesListTouched, setGitChangesListTouched] = createSignal(false)
|
||||
const [gitStagedOpen, setGitStagedOpen] = createSignal(true)
|
||||
const [gitUnstagedOpen, setGitUnstagedOpen] = createSignal(true)
|
||||
|
||||
const listLayoutKey = createMemo(() => (props.isPhoneLayout() ? "phone" : "nonphone"))
|
||||
|
||||
@@ -149,11 +163,28 @@ const RightPanel: Component<RightPanelProps> = (props) => {
|
||||
return layout === "phone" ? RIGHT_PANEL_FILES_LIST_OPEN_PHONE_KEY : RIGHT_PANEL_FILES_LIST_OPEN_NONPHONE_KEY
|
||||
}
|
||||
|
||||
const gitSectionStorageKey = (section: "staged" | "unstaged") => {
|
||||
const layout = listLayoutKey()
|
||||
if (section === "staged") {
|
||||
return layout === "phone"
|
||||
? RIGHT_PANEL_GIT_CHANGES_STAGED_OPEN_PHONE_KEY
|
||||
: RIGHT_PANEL_GIT_CHANGES_STAGED_OPEN_NONPHONE_KEY
|
||||
}
|
||||
return layout === "phone"
|
||||
? RIGHT_PANEL_GIT_CHANGES_UNSTAGED_OPEN_PHONE_KEY
|
||||
: RIGHT_PANEL_GIT_CHANGES_UNSTAGED_OPEN_NONPHONE_KEY
|
||||
}
|
||||
|
||||
const persistListOpen = (tab: "changes" | "git-changes" | "files", value: boolean) => {
|
||||
if (typeof window === "undefined") return
|
||||
window.localStorage.setItem(listOpenStorageKey(tab), value ? "true" : "false")
|
||||
}
|
||||
|
||||
const persistGitSectionOpen = (section: "staged" | "unstaged", value: boolean) => {
|
||||
if (typeof window === "undefined") return
|
||||
window.localStorage.setItem(gitSectionStorageKey(section), value ? "true" : "false")
|
||||
}
|
||||
|
||||
createEffect(() => {
|
||||
// Refresh persisted visibility when layout changes (phone vs non-phone).
|
||||
const layout = listLayoutKey()
|
||||
@@ -185,6 +216,12 @@ const RightPanel: Component<RightPanelProps> = (props) => {
|
||||
setGitChangesListOpen(true)
|
||||
setGitChangesListTouched(false)
|
||||
}
|
||||
|
||||
const stagedPersisted = readStoredBool(gitSectionStorageKey("staged"))
|
||||
setGitStagedOpen(stagedPersisted ?? true)
|
||||
|
||||
const unstagedPersisted = readStoredBool(gitSectionStorageKey("unstaged"))
|
||||
setGitUnstagedOpen(unstagedPersisted ?? true)
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
@@ -339,34 +376,56 @@ const RightPanel: Component<RightPanelProps> = (props) => {
|
||||
return getDefaultWorktreeSlug(props.instanceId)
|
||||
})
|
||||
|
||||
const gitChangesWorktreeSlug = createMemo(() => {
|
||||
if (getGitRepoStatus(props.instanceId) === false) return null
|
||||
const slug = worktreeSlugForViewer().trim()
|
||||
return slug ? slug : null
|
||||
})
|
||||
|
||||
const gitChangesWorktree = createMemo(() => {
|
||||
const slug = gitChangesWorktreeSlug()
|
||||
if (!slug) return null
|
||||
return getWorktrees(props.instanceId).find((worktree) => worktree.slug === slug) ?? null
|
||||
})
|
||||
|
||||
const gitChangesBranchLabel = createMemo(() => {
|
||||
const branch = gitChangesWorktree()?.branch?.trim()
|
||||
return branch || null
|
||||
})
|
||||
|
||||
const browserClient = createMemo(() => getOrCreateWorktreeClient(props.instanceId, worktreeSlugForViewer()))
|
||||
|
||||
const [gitStatusEntries, setGitStatusEntries] = createSignal<GitFileStatus[] | null>(null)
|
||||
const [gitStatusLoading, setGitStatusLoading] = createSignal(false)
|
||||
const [gitStatusError, setGitStatusError] = createSignal<string | null>(null)
|
||||
const [gitSelectedPath, setGitSelectedPath] = createSignal<string | null>(null)
|
||||
const [gitSelectedLoading, setGitSelectedLoading] = createSignal(false)
|
||||
const [gitSelectedError, setGitSelectedError] = createSignal<string | null>(null)
|
||||
const [gitSelectedBefore, setGitSelectedBefore] = createSignal<string | null>(null)
|
||||
const [gitSelectedAfter, setGitSelectedAfter] = createSignal<string | null>(null)
|
||||
|
||||
const gitMostChangedPath = createMemo<string | null>(() => {
|
||||
const entries = gitStatusEntries()
|
||||
if (!Array.isArray(entries) || entries.length === 0) return null
|
||||
const candidates = entries.filter((item) => item && item.status !== "deleted")
|
||||
if (candidates.length === 0) return null
|
||||
const best = candidates.reduce((currentBest, item) => {
|
||||
const bestScore = (currentBest?.added ?? 0) + (currentBest?.removed ?? 0)
|
||||
const score = (item?.added ?? 0) + (item?.removed ?? 0)
|
||||
if (score > bestScore) return item
|
||||
if (score < bestScore) return currentBest
|
||||
return String(item.path || "").localeCompare(String(currentBest?.path || "")) < 0 ? item : currentBest
|
||||
}, candidates[0])
|
||||
return typeof best?.path === "string" ? best.path : null
|
||||
const {
|
||||
gitStatusEntries,
|
||||
gitStatusLoading,
|
||||
gitStatusError,
|
||||
gitSelectedItemId,
|
||||
gitBulkSelectedItemIds,
|
||||
gitSelectedLoading,
|
||||
gitSelectedError,
|
||||
gitSelectedBefore,
|
||||
gitSelectedAfter,
|
||||
gitCommitMessage,
|
||||
gitCommitSubmitting,
|
||||
gitMostChangedItemId,
|
||||
setGitCommitMessage,
|
||||
handleGitRowClick,
|
||||
refreshGitStatus,
|
||||
insertGitChangeContext,
|
||||
submitGitCommit,
|
||||
stageGitFile,
|
||||
unstageGitFile,
|
||||
} = useGitChanges({
|
||||
t: props.t,
|
||||
instanceId: props.instanceId,
|
||||
rightPanelTab,
|
||||
worktreeSlug: worktreeSlugForViewer,
|
||||
isPhoneLayout: props.isPhoneLayout,
|
||||
promptInputApi: props.promptInputApi,
|
||||
closeGitList: () => setGitChangesListOpen(false),
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
// Reset tab state when worktree context changes.
|
||||
worktreeSlugForViewer()
|
||||
setBrowserPath(".")
|
||||
setBrowserEntries(null)
|
||||
@@ -375,111 +434,8 @@ const RightPanel: Component<RightPanelProps> = (props) => {
|
||||
setBrowserSelectedContent(null)
|
||||
setBrowserSelectedError(null)
|
||||
setBrowserSelectedLoading(false)
|
||||
|
||||
setGitStatusEntries(null)
|
||||
setGitStatusError(null)
|
||||
setGitStatusLoading(false)
|
||||
setGitSelectedPath(null)
|
||||
setGitSelectedLoading(false)
|
||||
setGitSelectedError(null)
|
||||
setGitSelectedBefore(null)
|
||||
setGitSelectedAfter(null)
|
||||
})
|
||||
|
||||
const loadGitStatus = async (force = false) => {
|
||||
if (!force && gitStatusEntries() !== null) return
|
||||
setGitStatusLoading(true)
|
||||
setGitStatusError(null)
|
||||
try {
|
||||
const list = await requestData<GitFileStatus[]>(browserClient().file.status(), "file.status")
|
||||
setGitStatusEntries(Array.isArray(list) ? list : [])
|
||||
} catch (error) {
|
||||
setGitStatusError(error instanceof Error ? error.message : "Failed to load git status")
|
||||
setGitStatusEntries([])
|
||||
} finally {
|
||||
setGitStatusLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function openGitFile(path: string) {
|
||||
setGitSelectedPath(path)
|
||||
setGitSelectedLoading(true)
|
||||
setGitSelectedError(null)
|
||||
setGitSelectedBefore(null)
|
||||
setGitSelectedAfter(null)
|
||||
|
||||
const list = gitStatusEntries() || []
|
||||
const entry = list.find((item) => item.path === path) || null
|
||||
if (entry?.status === "deleted") {
|
||||
setGitSelectedError("Deleted file diff is not available yet")
|
||||
setGitSelectedLoading(false)
|
||||
return
|
||||
}
|
||||
|
||||
// Phone: treat file selection as a commit action and close the overlay.
|
||||
if (props.isPhoneLayout()) {
|
||||
setGitChangesListOpen(false)
|
||||
}
|
||||
|
||||
try {
|
||||
const content = await requestData<FileContent>(browserClient().file.read({ path }), "file.read")
|
||||
const type = (content as any)?.type
|
||||
const encoding = (content as any)?.encoding
|
||||
if (type && type !== "text") {
|
||||
throw new Error("Binary file cannot be displayed")
|
||||
}
|
||||
if (encoding === "base64") {
|
||||
throw new Error("Binary file cannot be displayed")
|
||||
}
|
||||
const afterText = typeof (content as any)?.content === "string" ? ((content as any).content as string) : null
|
||||
if (afterText === null) {
|
||||
throw new Error("Unsupported file type")
|
||||
}
|
||||
|
||||
setGitSelectedAfter(afterText)
|
||||
|
||||
if (entry?.status === "added") {
|
||||
setGitSelectedBefore("")
|
||||
return
|
||||
}
|
||||
|
||||
const diffText =
|
||||
typeof (content as any)?.diff === "string" && String((content as any).diff).trim().length > 0
|
||||
? String((content as any).diff)
|
||||
: (content as any)?.patch
|
||||
? buildUnifiedDiffFromSdkPatch((content as any).patch)
|
||||
: ""
|
||||
|
||||
const beforeText = tryReverseApplyUnifiedDiff(afterText, diffText)
|
||||
if (beforeText === null) {
|
||||
throw new Error("Unable to calculate diff for this file")
|
||||
}
|
||||
setGitSelectedBefore(beforeText)
|
||||
} catch (error) {
|
||||
setGitSelectedError(error instanceof Error ? error.message : "Failed to load file changes")
|
||||
} finally {
|
||||
setGitSelectedLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
createEffect(() => {
|
||||
if (rightPanelTab() !== "git-changes") return
|
||||
const entries = gitStatusEntries()
|
||||
if (entries === null) return
|
||||
if (gitSelectedPath()) return
|
||||
const next = gitMostChangedPath()
|
||||
if (!next) return
|
||||
void openGitFile(next)
|
||||
})
|
||||
|
||||
const refreshGitStatus = async () => {
|
||||
await loadGitStatus(true)
|
||||
const selected = gitSelectedPath()
|
||||
if (selected) {
|
||||
void openGitFile(selected)
|
||||
}
|
||||
}
|
||||
|
||||
const bestDiffFile = createMemo<string | null>(() => {
|
||||
const diffs = props.activeSessionDiffs()
|
||||
if (!Array.isArray(diffs) || diffs.length === 0) return null
|
||||
@@ -680,21 +636,6 @@ const RightPanel: Component<RightPanelProps> = (props) => {
|
||||
setBrowserSelectedDirty(false)
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
if (rightPanelTab() !== "git-changes") return
|
||||
if (gitStatusLoading()) return
|
||||
if (gitStatusEntries() !== null) return
|
||||
void loadGitStatus()
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
if (rightPanelTab() === "git-changes") return
|
||||
setGitSelectedBefore(null)
|
||||
setGitSelectedAfter(null)
|
||||
setGitSelectedLoading(false)
|
||||
setGitSelectedError(null)
|
||||
})
|
||||
|
||||
const handleSelectChangesFile = (file: string, closeList: boolean) => {
|
||||
setSelectedFile(file)
|
||||
if (closeList) {
|
||||
@@ -911,12 +852,13 @@ const RightPanel: Component<RightPanelProps> = (props) => {
|
||||
entries={gitStatusEntries}
|
||||
statusLoading={gitStatusLoading}
|
||||
statusError={gitStatusError}
|
||||
selectedPath={gitSelectedPath}
|
||||
selectedItemId={gitSelectedItemId}
|
||||
selectedBulkItemIds={gitBulkSelectedItemIds}
|
||||
selectedLoading={gitSelectedLoading}
|
||||
selectedError={gitSelectedError}
|
||||
selectedBefore={gitSelectedBefore}
|
||||
selectedAfter={gitSelectedAfter}
|
||||
mostChangedPath={gitMostChangedPath}
|
||||
mostChangedItemId={gitMostChangedItemId}
|
||||
scopeKey={gitScopeKey}
|
||||
diffViewMode={diffViewMode}
|
||||
diffContextMode={diffContextMode}
|
||||
@@ -924,8 +866,28 @@ const RightPanel: Component<RightPanelProps> = (props) => {
|
||||
onViewModeChange={setDiffViewMode}
|
||||
onContextModeChange={setDiffContextMode}
|
||||
onWordWrapModeChange={setDiffWordWrapMode}
|
||||
onOpenFile={(path: string) => void openGitFile(path)}
|
||||
onRowClick={handleGitRowClick}
|
||||
onRefresh={() => void refreshGitStatus()}
|
||||
onInsertContext={insertGitChangeContext}
|
||||
onStageFile={stageGitFile}
|
||||
onUnstageFile={unstageGitFile}
|
||||
commitMessage={gitCommitMessage}
|
||||
commitSubmitting={gitCommitSubmitting}
|
||||
onCommitMessageInput={setGitCommitMessage}
|
||||
onSubmitCommit={() => void submitGitCommit()}
|
||||
branchLabel={gitChangesBranchLabel}
|
||||
stagedOpen={gitStagedOpen}
|
||||
unstagedOpen={gitUnstagedOpen}
|
||||
onToggleStagedOpen={() => {
|
||||
const next = !gitStagedOpen()
|
||||
setGitStagedOpen(next)
|
||||
persistGitSectionOpen("staged", next)
|
||||
}}
|
||||
onToggleUnstagedOpen={() => {
|
||||
const next = !gitUnstagedOpen()
|
||||
setGitUnstagedOpen(next)
|
||||
persistGitSectionOpen("unstaged", next)
|
||||
}}
|
||||
listOpen={gitChangesListOpen}
|
||||
onToggleList={toggleGitList}
|
||||
splitWidth={gitChangesSplitWidth}
|
||||
|
||||
@@ -0,0 +1,148 @@
|
||||
import type { File as SdkGitFileStatus } from "@opencode-ai/sdk/v2/client"
|
||||
import type { WorktreeGitStatusEntry } from "../../../../../../server/src/api-types"
|
||||
|
||||
import type { GitChangeEntry, GitChangeListItem, GitChangeSection, GitChangeStatus } from "./types"
|
||||
|
||||
function normalizeGitChangePath(path: unknown): string {
|
||||
if (typeof path !== "string") return ""
|
||||
const normalized = path.replace(/\\+/g, "/").replace(/^\.\//, "").trim()
|
||||
return normalized
|
||||
}
|
||||
|
||||
export function normalizeGitChangeStatus(status: unknown): GitChangeStatus {
|
||||
return typeof status === "string" && status.trim().length > 0 ? status : "modified"
|
||||
}
|
||||
|
||||
export function adaptSdkGitStatusEntry(entry: SdkGitFileStatus): GitChangeEntry {
|
||||
return {
|
||||
path: normalizeGitChangePath(entry?.path),
|
||||
originalPath: null,
|
||||
additions: typeof entry?.added === "number" ? entry.added : 0,
|
||||
deletions: typeof entry?.removed === "number" ? entry.removed : 0,
|
||||
status: normalizeGitChangeStatus(entry?.status),
|
||||
}
|
||||
}
|
||||
|
||||
export function adaptSdkGitStatusEntries(
|
||||
entries: SdkGitFileStatus[] | null | undefined,
|
||||
details?: WorktreeGitStatusEntry[] | null,
|
||||
): GitChangeEntry[] {
|
||||
const detailsByPath = new Map(
|
||||
(details ?? [])
|
||||
.map((entry) => {
|
||||
const path = normalizeGitChangePath(entry.path)
|
||||
return path ? [{ ...entry, path }, path] : null
|
||||
})
|
||||
.filter((entry): entry is [WorktreeGitStatusEntry, string] => Boolean(entry))
|
||||
.map(([entry, path]) => [path, entry] as const),
|
||||
)
|
||||
const adaptedByPath = new Map<string, GitChangeEntry>()
|
||||
|
||||
for (const entry of entries ?? []) {
|
||||
const adapted = adaptSdkGitStatusEntry(entry)
|
||||
if (!adapted.path) continue
|
||||
const detail = detailsByPath.get(adapted.path)
|
||||
adaptedByPath.set(adapted.path, {
|
||||
...adapted,
|
||||
originalPath: detail?.originalPath ? normalizeGitChangePath(detail.originalPath) : adapted.originalPath ?? null,
|
||||
stagedStatus: detail?.stagedStatus ?? null,
|
||||
unstagedStatus: detail?.unstagedStatus ?? null,
|
||||
stagedAdditions: detail?.stagedAdditions ?? 0,
|
||||
stagedDeletions: detail?.stagedDeletions ?? 0,
|
||||
unstagedAdditions: detail?.unstagedAdditions ?? 0,
|
||||
unstagedDeletions: detail?.unstagedDeletions ?? 0,
|
||||
})
|
||||
}
|
||||
|
||||
for (const detail of details ?? []) {
|
||||
const normalizedPath = normalizeGitChangePath(detail.path)
|
||||
if (!normalizedPath || adaptedByPath.has(normalizedPath)) continue
|
||||
adaptedByPath.set(normalizedPath, {
|
||||
path: normalizedPath,
|
||||
originalPath: detail.originalPath ? normalizeGitChangePath(detail.originalPath) : null,
|
||||
additions: 0,
|
||||
deletions: 0,
|
||||
status: detail.unstagedStatus ?? detail.stagedStatus ?? "modified",
|
||||
stagedStatus: detail.stagedStatus,
|
||||
unstagedStatus: detail.unstagedStatus,
|
||||
stagedAdditions: detail.stagedAdditions,
|
||||
stagedDeletions: detail.stagedDeletions,
|
||||
unstagedAdditions: detail.unstagedAdditions,
|
||||
unstagedDeletions: detail.unstagedDeletions,
|
||||
})
|
||||
}
|
||||
|
||||
return Array.from(adaptedByPath.values()).filter((entry) => entry.path.length > 0)
|
||||
}
|
||||
|
||||
function buildGitChangeListItemId(section: GitChangeSection, path: string): string {
|
||||
return `${section}:${path}`
|
||||
}
|
||||
|
||||
function splitGitChangePath(path: string) {
|
||||
const normalized = normalizeGitChangePath(path)
|
||||
const lastSlash = normalized.lastIndexOf("/")
|
||||
if (lastSlash === -1) {
|
||||
return { displayName: normalized, parentPath: "" }
|
||||
}
|
||||
return {
|
||||
displayName: normalized.slice(lastSlash + 1),
|
||||
parentPath: normalized.slice(0, lastSlash),
|
||||
}
|
||||
}
|
||||
|
||||
export function buildGitChangeListItems(entries: GitChangeEntry[] | null | undefined): GitChangeListItem[] {
|
||||
if (!Array.isArray(entries)) return []
|
||||
|
||||
const items: GitChangeListItem[] = []
|
||||
for (const entry of entries) {
|
||||
const pathParts = splitGitChangePath(entry.path)
|
||||
if (entry.stagedStatus) {
|
||||
items.push({
|
||||
id: buildGitChangeListItemId("staged", entry.path),
|
||||
path: entry.path,
|
||||
originalPath: entry.originalPath ?? null,
|
||||
section: "staged",
|
||||
status: entry.stagedStatus,
|
||||
additions: entry.stagedAdditions ?? 0,
|
||||
deletions: entry.stagedDeletions ?? 0,
|
||||
entry,
|
||||
displayName: pathParts.displayName,
|
||||
parentPath: pathParts.parentPath,
|
||||
})
|
||||
}
|
||||
if (entry.unstagedStatus) {
|
||||
items.push({
|
||||
id: buildGitChangeListItemId("unstaged", entry.path),
|
||||
path: entry.path,
|
||||
originalPath: entry.originalPath ?? null,
|
||||
section: "unstaged",
|
||||
status: entry.unstagedStatus,
|
||||
additions: entry.unstagedAdditions ?? entry.additions,
|
||||
deletions: entry.unstagedDeletions ?? entry.deletions,
|
||||
entry,
|
||||
displayName: pathParts.displayName,
|
||||
parentPath: pathParts.parentPath,
|
||||
})
|
||||
}
|
||||
if (!entry.stagedStatus && !entry.unstagedStatus) {
|
||||
items.push({
|
||||
id: buildGitChangeListItemId("unstaged", entry.path),
|
||||
path: entry.path,
|
||||
originalPath: entry.originalPath ?? null,
|
||||
section: "unstaged",
|
||||
status: entry.status,
|
||||
additions: entry.additions,
|
||||
deletions: entry.deletions,
|
||||
entry,
|
||||
displayName: pathParts.displayName,
|
||||
parentPath: pathParts.parentPath,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return items.sort((a, b) => {
|
||||
if (a.section !== b.section) return a.section.localeCompare(b.section)
|
||||
return a.path.localeCompare(b.path)
|
||||
})
|
||||
}
|
||||
@@ -1,11 +1,20 @@
|
||||
import { For, Show, Suspense, createMemo, lazy, type Accessor, type Component, type JSX } from "solid-js"
|
||||
import type { File as GitFileStatus } from "@opencode-ai/sdk/v2/client"
|
||||
import {
|
||||
For,
|
||||
Show,
|
||||
Suspense,
|
||||
createMemo,
|
||||
lazy,
|
||||
type Accessor,
|
||||
type Component,
|
||||
type JSX,
|
||||
} from "solid-js"
|
||||
|
||||
import { RefreshCw } from "lucide-solid"
|
||||
import { ChevronDown, ChevronRight, GitBranch, RefreshCw } from "lucide-solid"
|
||||
|
||||
import DiffToolbar from "../components/DiffToolbar"
|
||||
import SplitFilePanel from "../components/SplitFilePanel"
|
||||
import type { DiffContextMode, DiffViewMode, DiffWordWrapMode } from "../types"
|
||||
import type { DiffContextMode, DiffViewMode, DiffWordWrapMode, GitChangeEntry, GitChangeListItem } from "../types"
|
||||
import { buildGitChangeListItems } from "../git-changes-model"
|
||||
|
||||
const LazyMonacoDiffViewer = lazy(() =>
|
||||
import("../../../../file-viewer/monaco-diff-viewer").then((module) => ({ default: module.MonacoDiffViewer })),
|
||||
@@ -16,16 +25,17 @@ interface GitChangesTabProps {
|
||||
|
||||
activeSessionId: Accessor<string | null>
|
||||
|
||||
entries: Accessor<GitFileStatus[] | null>
|
||||
entries: Accessor<GitChangeEntry[] | null>
|
||||
statusLoading: Accessor<boolean>
|
||||
statusError: Accessor<string | null>
|
||||
|
||||
selectedPath: Accessor<string | null>
|
||||
selectedItemId: Accessor<string | null>
|
||||
selectedBulkItemIds: Accessor<Set<string>>
|
||||
selectedLoading: Accessor<boolean>
|
||||
selectedError: Accessor<string | null>
|
||||
selectedBefore: Accessor<string | null>
|
||||
selectedAfter: Accessor<string | null>
|
||||
mostChangedPath: Accessor<string | null>
|
||||
mostChangedItemId: Accessor<string | null>
|
||||
|
||||
scopeKey: Accessor<string>
|
||||
|
||||
@@ -36,8 +46,21 @@ interface GitChangesTabProps {
|
||||
onContextModeChange: (mode: DiffContextMode) => void
|
||||
onWordWrapModeChange: (mode: DiffWordWrapMode) => void
|
||||
|
||||
onOpenFile: (path: string) => void
|
||||
onRowClick: (item: GitChangeListItem, event: MouseEvent) => void
|
||||
onRefresh: () => void
|
||||
onInsertContext: (item: GitChangeListItem, selection: { startLine: number; endLine: number }) => void
|
||||
onStageFile: (item: GitChangeListItem) => void
|
||||
onUnstageFile: (item: GitChangeListItem) => void
|
||||
commitMessage: Accessor<string>
|
||||
commitSubmitting: Accessor<boolean>
|
||||
onCommitMessageInput: (value: string) => void
|
||||
onSubmitCommit: () => void
|
||||
branchLabel: Accessor<string | null>
|
||||
|
||||
stagedOpen: Accessor<boolean>
|
||||
unstagedOpen: Accessor<boolean>
|
||||
onToggleStagedOpen: () => void
|
||||
onToggleUnstagedOpen: () => void
|
||||
|
||||
listOpen: Accessor<boolean>
|
||||
onToggleList: () => void
|
||||
@@ -52,48 +75,54 @@ const GitChangesTab: Component<GitChangesTabProps> = (props) => {
|
||||
const hasSession = createMemo(() => Boolean(sessionId() && sessionId() !== "info"))
|
||||
const entries = createMemo(() => (hasSession() ? props.entries() : null))
|
||||
|
||||
const sorted = createMemo<GitFileStatus[]>(() => {
|
||||
const sorted = createMemo<GitChangeEntry[]>(() => {
|
||||
const list = entries()
|
||||
if (!Array.isArray(list)) return []
|
||||
return [...list].sort((a, b) => String(a.path || "").localeCompare(String(b.path || "")))
|
||||
})
|
||||
|
||||
const listItems = createMemo<GitChangeListItem[]>(() => buildGitChangeListItems(sorted()))
|
||||
|
||||
const totals = createMemo(() => {
|
||||
return sorted().reduce(
|
||||
return listItems().reduce(
|
||||
(acc, item) => {
|
||||
acc.additions += typeof item.added === "number" ? item.added : 0
|
||||
acc.deletions += typeof item.removed === "number" ? item.removed : 0
|
||||
acc.additions += typeof item.additions === "number" ? item.additions : 0
|
||||
acc.deletions += typeof item.deletions === "number" ? item.deletions : 0
|
||||
return acc
|
||||
},
|
||||
{ additions: 0, deletions: 0 },
|
||||
)
|
||||
})
|
||||
const stagedItems = createMemo(() => listItems().filter((item) => item.section === "staged"))
|
||||
const unstagedItems = createMemo(() => listItems().filter((item) => item.section === "unstaged"))
|
||||
const canCommit = createMemo(() => stagedItems().length > 0 && props.commitMessage().trim().length > 0 && !props.commitSubmitting())
|
||||
|
||||
const nonDeleted = createMemo(() => sorted().filter((item) => item && item.status !== "deleted"))
|
||||
|
||||
const selectedEntry = createMemo<GitFileStatus | null>(() => {
|
||||
const list = sorted()
|
||||
const selectedPath = props.selectedPath()
|
||||
const fallbackPath = props.mostChangedPath()
|
||||
const selectedEntry = createMemo<GitChangeEntry | null>(() => {
|
||||
const list = listItems()
|
||||
const selectedId = props.selectedItemId()
|
||||
const fallbackId = props.mostChangedItemId()
|
||||
const found =
|
||||
list.find((item) => item.path === selectedPath) ||
|
||||
(fallbackPath ? list.find((item) => item.path === fallbackPath) : undefined)
|
||||
return found ?? null
|
||||
list.find((item) => item.id === selectedId) ||
|
||||
(fallbackId ? list.find((item) => item.id === fallbackId) : undefined)
|
||||
return found?.entry ?? null
|
||||
})
|
||||
|
||||
const emptyViewerMessage = createMemo(() => {
|
||||
if (!hasSession()) return props.t("instanceShell.gitChanges.noSessionSelected")
|
||||
const currentEntries = entries()
|
||||
if (currentEntries === null) return props.t("instanceShell.gitChanges.loading")
|
||||
if (nonDeleted().length === 0) return props.t("instanceShell.gitChanges.empty")
|
||||
if (listItems().length === 0) return props.t("instanceShell.gitChanges.empty")
|
||||
return props.t("instanceShell.filesShell.viewerEmpty")
|
||||
})
|
||||
|
||||
const binaryViewerActive = createMemo(() => props.selectedError() === props.t("instanceShell.gitChanges.binaryViewer"))
|
||||
|
||||
const renderContent = (): JSX.Element => {
|
||||
const totalsValue = totals()
|
||||
const selected = selectedEntry()
|
||||
const sortedList = sorted()
|
||||
const nonDeletedList = nonDeleted()
|
||||
const allItems = listItems()
|
||||
const stagedList = stagedItems()
|
||||
const unstagedList = unstagedItems()
|
||||
|
||||
const renderViewer = () => (
|
||||
<div class="file-viewer-panel flex-1">
|
||||
@@ -109,7 +138,7 @@ const GitChangesTab: Component<GitChangesTabProps> = (props) => {
|
||||
selected &&
|
||||
props.selectedBefore() !== null &&
|
||||
props.selectedAfter() !== null &&
|
||||
selected.status !== "deleted"
|
||||
true
|
||||
? {
|
||||
path: selected.path,
|
||||
before: props.selectedBefore() as string,
|
||||
@@ -139,6 +168,14 @@ const GitChangesTab: Component<GitChangesTabProps> = (props) => {
|
||||
viewMode={props.diffViewMode()}
|
||||
contextMode={props.diffContextMode()}
|
||||
wordWrap={props.diffWordWrapMode()}
|
||||
insertContextLabel={props.t("instanceShell.gitChanges.actions.insertContext")}
|
||||
onRequestInsertContext={binaryViewerActive() ? undefined : (selection) => {
|
||||
const selectedId = props.selectedItemId()
|
||||
if (!selectedId) return
|
||||
const item = listItems().find((entry) => entry.id === selectedId)
|
||||
if (!item) return
|
||||
props.onInsertContext(item, selection)
|
||||
}}
|
||||
/>
|
||||
</Suspense>
|
||||
)}
|
||||
@@ -163,66 +200,149 @@ const GitChangesTab: Component<GitChangesTabProps> = (props) => {
|
||||
|
||||
const renderEmptyList = () => <div class="p-3 text-xs text-secondary">{emptyViewerMessage()}</div>
|
||||
|
||||
const renderListPanel = () => (
|
||||
<Show when={nonDeletedList.length > 0} fallback={renderEmptyList()}>
|
||||
<For each={sortedList}>
|
||||
{(item) => (
|
||||
<div
|
||||
class={`file-list-item ${props.selectedPath() === item.path ? "file-list-item-active" : ""}`}
|
||||
onClick={() => {
|
||||
props.onOpenFile(item.path)
|
||||
}}
|
||||
>
|
||||
<div class="file-list-item-content">
|
||||
<div class="file-list-item-path" title={item.path}>
|
||||
<span class="file-path-text">{item.path}</span>
|
||||
</div>
|
||||
<div class="file-list-item-stats">
|
||||
<Show when={item.status === "deleted"}>
|
||||
<span class="text-[10px] text-secondary">{props.t("instanceShell.gitChanges.deleted")}</span>
|
||||
</Show>
|
||||
<Show when={item.status !== "deleted"}>
|
||||
<>
|
||||
<span class="file-list-item-additions">+{item.added}</span>
|
||||
<span class="file-list-item-deletions">-{item.removed}</span>
|
||||
</>
|
||||
</Show>
|
||||
</div>
|
||||
const renderListItem = (item: GitChangeListItem) => {
|
||||
const isBulkSelected = createMemo(() => props.selectedBulkItemIds().has(item.id))
|
||||
const actionLabel =
|
||||
item.section === "staged"
|
||||
? props.t("instanceShell.gitChanges.actions.unstage")
|
||||
: props.t("instanceShell.gitChanges.actions.stage")
|
||||
|
||||
const triggerAction = () => {
|
||||
if (item.section === "staged") props.onUnstageFile(item)
|
||||
else props.onStageFile(item)
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
class={`file-list-item git-change-list-item ${props.selectedItemId() === item.id ? "file-list-item-active" : ""} ${isBulkSelected() ? "git-change-list-item-bulk-selected" : ""}`}
|
||||
onMouseDown={(event) => {
|
||||
if (event.shiftKey || event.ctrlKey || event.metaKey) {
|
||||
event.preventDefault()
|
||||
}
|
||||
}}
|
||||
onClick={(event) => props.onRowClick(item, event)}
|
||||
title={item.path}
|
||||
>
|
||||
<div class="file-list-item-content" title={item.path}>
|
||||
<div class="file-list-item-path" title={item.path}>
|
||||
<span class="file-path-text">{item.path}</span>
|
||||
</div>
|
||||
<div class="git-change-list-item-right">
|
||||
<div class="file-list-item-stats">
|
||||
<span class="file-list-item-additions">+{item.additions}</span>
|
||||
<span class="file-list-item-deletions">-{item.deletions}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
</Show>
|
||||
</div>
|
||||
<div class="git-change-list-item-actions-zone">
|
||||
<div class="git-change-list-item-actions">
|
||||
<button
|
||||
type="button"
|
||||
class="git-change-row-action"
|
||||
title={actionLabel}
|
||||
aria-label={actionLabel}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation()
|
||||
triggerAction()
|
||||
}}
|
||||
>
|
||||
<span
|
||||
class={`git-change-row-action-glyph ${item.section === "staged" ? "git-change-row-action-glyph-minus" : "git-change-row-action-glyph-plus"}`}
|
||||
aria-hidden="true"
|
||||
>
|
||||
<span class="git-change-row-action-bar git-change-row-action-bar-horizontal" />
|
||||
<Show when={item.section !== "staged"}>
|
||||
<span class="git-change-row-action-bar git-change-row-action-bar-vertical" />
|
||||
</Show>
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const renderSection = (
|
||||
title: string,
|
||||
items: GitChangeListItem[],
|
||||
isOpen: boolean,
|
||||
onToggle: () => void,
|
||||
) => (
|
||||
<div class="git-change-section">
|
||||
<button type="button" class="git-change-section-header" onClick={onToggle}>
|
||||
<span class="git-change-section-header-main">
|
||||
<span class="git-change-section-chevron">
|
||||
{isOpen ? <ChevronDown class="h-3.5 w-3.5" /> : <ChevronRight class="h-3.5 w-3.5" />}
|
||||
</span>
|
||||
<span class="git-change-section-title">{title}</span>
|
||||
</span>
|
||||
<span class="git-change-section-count">{items.length}</span>
|
||||
</button>
|
||||
<Show when={isOpen}>
|
||||
<div class="git-change-section-items">
|
||||
<For each={items}>{(item) => renderListItem(item)}</For>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
)
|
||||
|
||||
const renderListOverlay = () => (
|
||||
<Show when={nonDeletedList.length > 0} fallback={renderEmptyList()}>
|
||||
<For each={sortedList}>
|
||||
{(item) => (
|
||||
<div
|
||||
class={`file-list-item ${props.selectedPath() === item.path ? "file-list-item-active" : ""}`}
|
||||
onClick={() => props.onOpenFile(item.path)}
|
||||
title={item.path}
|
||||
>
|
||||
<div class="file-list-item-content">
|
||||
<div class="file-list-item-path" title={item.path}>
|
||||
<span class="file-path-text">{item.path}</span>
|
||||
</div>
|
||||
<div class="file-list-item-stats">
|
||||
<Show when={item.status === "deleted"}>
|
||||
<span class="text-[10px] text-secondary">{props.t("instanceShell.gitChanges.deleted")}</span>
|
||||
</Show>
|
||||
<Show when={item.status !== "deleted"}>
|
||||
<>
|
||||
<span class="file-list-item-additions">+{item.added}</span>
|
||||
<span class="file-list-item-deletions">-{item.removed}</span>
|
||||
</>
|
||||
const renderGroupedList = () => (
|
||||
<Show when={allItems.length > 0} fallback={renderEmptyList()}>
|
||||
<div class="git-change-sections">
|
||||
<div class="git-change-section">
|
||||
<button type="button" class="git-change-section-header" onClick={props.onToggleStagedOpen}>
|
||||
<span class="git-change-section-header-main">
|
||||
<span class="git-change-section-chevron">
|
||||
{props.stagedOpen() ? <ChevronDown class="h-3.5 w-3.5" /> : <ChevronRight class="h-3.5 w-3.5" />}
|
||||
</span>
|
||||
<span class="git-change-section-title-row">
|
||||
<span class="git-change-section-title">{props.t("instanceShell.gitChanges.sections.staged")}</span>
|
||||
<Show when={props.branchLabel()}>
|
||||
{(label) => (
|
||||
<span class="status-indicator session-status-list worktree-indicator git-change-section-badge" title={`Branch: ${label()}`}>
|
||||
<GitBranch class="w-3.5 h-3.5" aria-hidden="true" />
|
||||
<span class="worktree-indicator-label">{label()}</span>
|
||||
</span>
|
||||
)}
|
||||
</Show>
|
||||
</span>
|
||||
</span>
|
||||
<span class="git-change-section-count">{stagedList.length}</span>
|
||||
</button>
|
||||
<Show when={props.stagedOpen()}>
|
||||
<div class="git-change-section-items">
|
||||
<div class="git-change-commit-box">
|
||||
<div class="git-change-commit-input-wrap">
|
||||
<textarea
|
||||
class="git-change-commit-input"
|
||||
value={props.commitMessage()}
|
||||
rows={1}
|
||||
placeholder={props.t("instanceShell.gitChanges.commit.placeholder")}
|
||||
onInput={(event) => props.onCommitMessageInput(event.currentTarget.value)}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
class="git-change-commit-button git-change-commit-button-overlay"
|
||||
disabled={!canCommit()}
|
||||
onClick={() => props.onSubmitCommit()}
|
||||
>
|
||||
{props.commitSubmitting()
|
||||
? props.t("instanceShell.gitChanges.commit.submitting")
|
||||
: props.t("instanceShell.gitChanges.commit.submit")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<For each={stagedList}>{(item) => renderListItem(item)}</For>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
{renderSection(
|
||||
props.t("instanceShell.gitChanges.sections.unstaged"),
|
||||
unstagedList,
|
||||
props.unstagedOpen(),
|
||||
props.onToggleUnstagedOpen,
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</Show>
|
||||
)
|
||||
|
||||
@@ -266,7 +386,7 @@ const GitChangesTab: Component<GitChangesTabProps> = (props) => {
|
||||
/>
|
||||
</>
|
||||
}
|
||||
list={{ panel: renderListPanel, overlay: renderListOverlay }}
|
||||
list={{ panel: renderGroupedList, overlay: renderGroupedList }}
|
||||
viewer={renderViewer()}
|
||||
listOpen={props.listOpen()}
|
||||
onToggleList={props.onToggleList}
|
||||
|
||||
@@ -5,3 +5,40 @@ export type DiffViewMode = "split" | "unified"
|
||||
export type DiffContextMode = "expanded" | "collapsed"
|
||||
|
||||
export type DiffWordWrapMode = "on" | "off"
|
||||
|
||||
export type GitChangeStatus = "added" | "modified" | "deleted" | "renamed" | "copied" | "untracked" | string
|
||||
|
||||
export interface GitChangeEntry {
|
||||
path: string
|
||||
originalPath?: string | null
|
||||
additions: number
|
||||
deletions: number
|
||||
status: GitChangeStatus
|
||||
stagedStatus?: GitChangeStatus | null
|
||||
unstagedStatus?: GitChangeStatus | null
|
||||
stagedAdditions?: number
|
||||
stagedDeletions?: number
|
||||
unstagedAdditions?: number
|
||||
unstagedDeletions?: number
|
||||
}
|
||||
|
||||
export type GitChangeSection = "staged" | "unstaged"
|
||||
|
||||
export interface GitChangeListItem {
|
||||
id: string
|
||||
path: string
|
||||
originalPath?: string | null
|
||||
section: GitChangeSection
|
||||
status: GitChangeStatus
|
||||
additions: number
|
||||
deletions: number
|
||||
entry: GitChangeEntry
|
||||
displayName: string
|
||||
parentPath: string
|
||||
}
|
||||
|
||||
export interface GitSelectionDescriptor {
|
||||
itemId: string | null
|
||||
path: string | null
|
||||
section: GitChangeSection | null
|
||||
}
|
||||
|
||||
@@ -0,0 +1,470 @@
|
||||
import { createEffect, createMemo, createSignal, onCleanup, type Accessor } from "solid-js"
|
||||
import type { File as GitFileStatus } from "@opencode-ai/sdk/v2/client"
|
||||
import type { PromptInputApi } from "../../../prompt-input/types"
|
||||
import type { GitChangeEntry, GitChangeListItem, GitSelectionDescriptor, RightPanelTab } from "./types"
|
||||
|
||||
import { getOrCreateWorktreeClient } from "../../../../stores/worktrees"
|
||||
import { requestData } from "../../../../lib/opencode-api"
|
||||
import { serverApi } from "../../../../lib/api-client"
|
||||
import { serverEvents } from "../../../../lib/server-events"
|
||||
import { showToastNotification } from "../../../../lib/notifications"
|
||||
import { adaptSdkGitStatusEntries, buildGitChangeListItems } from "./git-changes-model"
|
||||
|
||||
type UseGitChangesOptions = {
|
||||
t: (key: string, vars?: Record<string, any>) => string
|
||||
instanceId: string
|
||||
rightPanelTab: Accessor<RightPanelTab>
|
||||
worktreeSlug: Accessor<string>
|
||||
isPhoneLayout: Accessor<boolean>
|
||||
promptInputApi: Accessor<PromptInputApi | null>
|
||||
closeGitList: () => void
|
||||
}
|
||||
|
||||
export function useGitChanges(options: UseGitChangesOptions) {
|
||||
const [gitStatusEntries, setGitStatusEntries] = createSignal<GitChangeEntry[] | null>(null)
|
||||
const [gitStatusLoading, setGitStatusLoading] = createSignal(false)
|
||||
const [gitStatusError, setGitStatusError] = createSignal<string | null>(null)
|
||||
const [gitSelectedItemId, setGitSelectedItemId] = createSignal<string | null>(null)
|
||||
const [gitBulkSelectedItemIds, setGitBulkSelectedItemIds] = createSignal<Set<string>>(new Set())
|
||||
const [gitBulkSelectionAnchorId, setGitBulkSelectionAnchorId] = createSignal<string | null>(null)
|
||||
const [gitSelectedLoading, setGitSelectedLoading] = createSignal(false)
|
||||
const [gitSelectedError, setGitSelectedError] = createSignal<string | null>(null)
|
||||
const [gitSelectedBefore, setGitSelectedBefore] = createSignal<string | null>(null)
|
||||
const [gitSelectedAfter, setGitSelectedAfter] = createSignal<string | null>(null)
|
||||
const [gitCommitMessage, setGitCommitMessage] = createSignal("")
|
||||
const [gitCommitSubmitting, setGitCommitSubmitting] = createSignal(false)
|
||||
let gitStatusRequestVersion = 0
|
||||
let gitDiffRequestVersion = 0
|
||||
let passiveGitRefreshInFlight = false
|
||||
let pendingGitPassiveRefreshOptions: { forceReloadSelectedDiff?: boolean } | null = null
|
||||
let previousGitChangesActivationKey: string | null = null
|
||||
|
||||
const gitListItems = createMemo(() => buildGitChangeListItems(gitStatusEntries()))
|
||||
|
||||
const clearGitBulkSelection = () => {
|
||||
setGitBulkSelectedItemIds((current) => (current.size === 0 ? current : new Set<string>()))
|
||||
setGitBulkSelectionAnchorId(null)
|
||||
}
|
||||
|
||||
const toggleGitBulkSelection = (itemId: string) => {
|
||||
setGitBulkSelectedItemIds((current) => {
|
||||
const next = new Set(current)
|
||||
if (next.has(itemId)) next.delete(itemId)
|
||||
else next.add(itemId)
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
const addGitBulkRange = (anchorId: string, itemId: string) => {
|
||||
const items = gitListItems()
|
||||
const anchorIndex = items.findIndex((entry) => entry.id === anchorId)
|
||||
const itemIndex = items.findIndex((entry) => entry.id === itemId)
|
||||
if (anchorIndex < 0 || itemIndex < 0) {
|
||||
setGitBulkSelectedItemIds((current) => {
|
||||
const next = new Set(current)
|
||||
next.add(itemId)
|
||||
return next
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
const start = Math.min(anchorIndex, itemIndex)
|
||||
const end = Math.max(anchorIndex, itemIndex)
|
||||
const rangeIds = items.slice(start, end + 1).map((entry) => entry.id)
|
||||
setGitBulkSelectedItemIds((current) => {
|
||||
const next = new Set(current)
|
||||
for (const rangeId of rangeIds) {
|
||||
next.add(rangeId)
|
||||
}
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
const describeGitSelection = (itemId: string | null): GitSelectionDescriptor => {
|
||||
if (!itemId) {
|
||||
return { itemId: null, path: null, section: null }
|
||||
}
|
||||
const match = gitListItems().find((item) => item.id === itemId) ?? null
|
||||
return {
|
||||
itemId,
|
||||
path: match?.path ?? null,
|
||||
section: match?.section ?? null,
|
||||
}
|
||||
}
|
||||
|
||||
const gitMostChangedItemId = createMemo<string | null>(() => {
|
||||
const items = gitListItems()
|
||||
if (items.length === 0) return null
|
||||
const candidates = items.filter((item) => item.status !== "deleted")
|
||||
if (candidates.length === 0) return null
|
||||
const best = candidates.reduce((currentBest, item) => {
|
||||
const bestScore = (currentBest?.additions ?? 0) + (currentBest?.deletions ?? 0)
|
||||
const score = (item.additions ?? 0) + (item.deletions ?? 0)
|
||||
if (score > bestScore) return item
|
||||
if (score < bestScore) return currentBest
|
||||
return String(item.id || "").localeCompare(String(currentBest?.id || "")) < 0 ? item : currentBest
|
||||
}, candidates[0])
|
||||
return typeof best?.id === "string" ? best.id : null
|
||||
})
|
||||
|
||||
const resolveValidGitSelection = (selection: GitSelectionDescriptor): string | null => {
|
||||
const items = gitListItems()
|
||||
if (items.length === 0) return null
|
||||
if (selection.itemId && items.some((item) => item.id === selection.itemId)) return selection.itemId
|
||||
if (selection.path && selection.section) {
|
||||
const oppositeSection = selection.section === "staged" ? "unstaged" : "staged"
|
||||
const moved = items.find((item) => item.path === selection.path && item.section === oppositeSection)
|
||||
if (moved) return moved.id
|
||||
const samePath = items.find((item) => item.path === selection.path)
|
||||
if (samePath) return samePath.id
|
||||
}
|
||||
return gitMostChangedItemId()
|
||||
}
|
||||
|
||||
const describeGitSelectionFingerprint = (itemId: string | null) => {
|
||||
if (!itemId) return null
|
||||
const item = gitListItems().find((entry) => entry.id === itemId) ?? null
|
||||
if (!item) return null
|
||||
return `${item.path}::${item.originalPath ?? ""}::${item.section}::${item.status}::${item.additions}::${item.deletions}`
|
||||
}
|
||||
|
||||
const clearSelectedGitDiff = () => {
|
||||
setGitSelectedError(null)
|
||||
setGitSelectedBefore(null)
|
||||
setGitSelectedAfter(null)
|
||||
}
|
||||
|
||||
const clearSelectedGitDiffAndSelection = () => {
|
||||
setGitSelectedItemId(null)
|
||||
clearGitBulkSelection()
|
||||
setGitSelectedLoading(false)
|
||||
clearSelectedGitDiff()
|
||||
}
|
||||
|
||||
const pruneGitBulkSelection = () => {
|
||||
const validIds = new Set(gitListItems().map((item) => item.id))
|
||||
setGitBulkSelectedItemIds((current) => {
|
||||
if (current.size === 0) return current
|
||||
const next = new Set<string>()
|
||||
for (const itemId of current) {
|
||||
if (validIds.has(itemId)) next.add(itemId)
|
||||
}
|
||||
return next.size === current.size ? current : next
|
||||
})
|
||||
|
||||
const anchorId = gitBulkSelectionAnchorId()
|
||||
if (anchorId && !validIds.has(anchorId)) {
|
||||
setGitBulkSelectionAnchorId(null)
|
||||
}
|
||||
}
|
||||
|
||||
createEffect(() => {
|
||||
gitListItems()
|
||||
pruneGitBulkSelection()
|
||||
})
|
||||
|
||||
const loadGitStatus = async (force = false) => {
|
||||
if (!force && gitStatusEntries() !== null) return
|
||||
const slug = options.worktreeSlug()
|
||||
const client = getOrCreateWorktreeClient(options.instanceId, slug)
|
||||
const requestVersion = ++gitStatusRequestVersion
|
||||
setGitStatusLoading(true)
|
||||
setGitStatusError(null)
|
||||
try {
|
||||
const sdkStatusPromise = requestData<GitFileStatus[]>(client.file.status(), "file.status")
|
||||
const detailList = await serverApi.fetchWorktreeGitStatus(options.instanceId, slug)
|
||||
if (requestVersion !== gitStatusRequestVersion) return
|
||||
if (slug !== options.worktreeSlug()) return
|
||||
|
||||
const sdkResult = await Promise.race([
|
||||
sdkStatusPromise.then((value) => ({ kind: "fulfilled" as const, value })),
|
||||
new Promise<{ kind: "timeout" }>((resolve) => setTimeout(() => resolve({ kind: "timeout" }), 1500)),
|
||||
]).catch(() => null)
|
||||
|
||||
const sdkList = sdkResult && sdkResult.kind === "fulfilled" ? sdkResult.value : null
|
||||
setGitStatusEntries(adaptSdkGitStatusEntries(sdkList, detailList))
|
||||
} catch (error) {
|
||||
if (requestVersion !== gitStatusRequestVersion) return
|
||||
if (slug !== options.worktreeSlug()) return
|
||||
setGitStatusError(error instanceof Error ? error.message : "Failed to load git status")
|
||||
setGitStatusEntries([])
|
||||
} finally {
|
||||
if (requestVersion !== gitStatusRequestVersion) return
|
||||
if (slug !== options.worktreeSlug()) return
|
||||
setGitStatusLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function openGitFile(itemId: string) {
|
||||
const requestVersion = ++gitDiffRequestVersion
|
||||
setGitSelectedItemId(itemId)
|
||||
setGitSelectedLoading(true)
|
||||
clearSelectedGitDiff()
|
||||
|
||||
const item = gitListItems().find((entry) => entry.id === itemId) || null
|
||||
if (!item) {
|
||||
if (requestVersion !== gitDiffRequestVersion) return
|
||||
clearSelectedGitDiffAndSelection()
|
||||
return
|
||||
}
|
||||
|
||||
if (options.isPhoneLayout()) {
|
||||
options.closeGitList()
|
||||
}
|
||||
|
||||
try {
|
||||
const diff = await serverApi.fetchWorktreeGitDiff(options.instanceId, options.worktreeSlug(), {
|
||||
path: item.path,
|
||||
originalPath: item.originalPath ?? null,
|
||||
scope: item.section,
|
||||
})
|
||||
if (requestVersion !== gitDiffRequestVersion || gitSelectedItemId() !== itemId) return
|
||||
if (diff.isBinary) {
|
||||
setGitSelectedError(options.t("instanceShell.gitChanges.binaryViewer"))
|
||||
return
|
||||
}
|
||||
setGitSelectedBefore(diff.before)
|
||||
setGitSelectedAfter(diff.after)
|
||||
} catch (error) {
|
||||
if (requestVersion !== gitDiffRequestVersion || gitSelectedItemId() !== itemId) return
|
||||
setGitSelectedError(error instanceof Error ? error.message : "Failed to load file changes")
|
||||
} finally {
|
||||
if (requestVersion !== gitDiffRequestVersion || gitSelectedItemId() !== itemId) return
|
||||
setGitSelectedLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const passiveRefreshGitStatus = async (optionsArg?: { forceReloadSelectedDiff?: boolean }) => {
|
||||
if (options.rightPanelTab() !== "git-changes") return
|
||||
if (passiveGitRefreshInFlight) {
|
||||
pendingGitPassiveRefreshOptions = {
|
||||
forceReloadSelectedDiff:
|
||||
pendingGitPassiveRefreshOptions?.forceReloadSelectedDiff || optionsArg?.forceReloadSelectedDiff || false,
|
||||
}
|
||||
return
|
||||
}
|
||||
if (gitCommitSubmitting()) return
|
||||
|
||||
passiveGitRefreshInFlight = true
|
||||
const refreshSelectionId = gitSelectedItemId()
|
||||
const previousSelection = describeGitSelection(gitSelectedItemId())
|
||||
const previousFingerprint = describeGitSelectionFingerprint(previousSelection.itemId)
|
||||
const hadSelectedDiff =
|
||||
previousSelection.itemId !== null &&
|
||||
(gitSelectedBefore() !== null || gitSelectedAfter() !== null || gitSelectedError() !== null)
|
||||
|
||||
try {
|
||||
await loadGitStatus(true)
|
||||
if (gitSelectedItemId() !== refreshSelectionId) return
|
||||
const nextSelection = resolveValidGitSelection(previousSelection)
|
||||
setGitSelectedItemId(nextSelection)
|
||||
|
||||
if (!nextSelection) {
|
||||
clearSelectedGitDiff()
|
||||
return
|
||||
}
|
||||
|
||||
const nextFingerprint = describeGitSelectionFingerprint(nextSelection)
|
||||
const shouldReloadSelectedDiff =
|
||||
optionsArg?.forceReloadSelectedDiff ||
|
||||
!hadSelectedDiff ||
|
||||
previousFingerprint !== nextFingerprint ||
|
||||
previousSelection.itemId === nextSelection
|
||||
|
||||
if (shouldReloadSelectedDiff) {
|
||||
await openGitFile(nextSelection)
|
||||
}
|
||||
} finally {
|
||||
passiveGitRefreshInFlight = false
|
||||
if (pendingGitPassiveRefreshOptions) {
|
||||
const nextOptions = pendingGitPassiveRefreshOptions
|
||||
pendingGitPassiveRefreshOptions = null
|
||||
void passiveRefreshGitStatus(nextOptions)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const mutateGitFile = async (item: GitChangeListItem, action: "stage" | "unstage") => {
|
||||
const currentSelection = describeGitSelection(gitSelectedItemId())
|
||||
const fallbackSelection = currentSelection.path === item.path ? currentSelection : describeGitSelection(item.id)
|
||||
const selectedIds = gitBulkSelectedItemIds()
|
||||
const selectedItems = gitListItems().filter((candidate) => selectedIds.has(candidate.id))
|
||||
const bulkTargets = selectedItems.filter((candidate) => candidate.section === item.section)
|
||||
const targetItems = bulkTargets.some((candidate) => candidate.id === item.id) ? bulkTargets : [item]
|
||||
const targetPaths = Array.from(new Set(targetItems.map((candidate) => candidate.path)))
|
||||
try {
|
||||
if (action === "stage") {
|
||||
await serverApi.stageWorktreeGitPaths(options.instanceId, options.worktreeSlug(), { paths: targetPaths })
|
||||
} else {
|
||||
await serverApi.unstageWorktreeGitPaths(options.instanceId, options.worktreeSlug(), { paths: targetPaths })
|
||||
}
|
||||
|
||||
await loadGitStatus(true)
|
||||
clearGitBulkSelection()
|
||||
const nextSelection = resolveValidGitSelection(fallbackSelection)
|
||||
setGitSelectedItemId(nextSelection)
|
||||
if (nextSelection) {
|
||||
await openGitFile(nextSelection)
|
||||
} else {
|
||||
clearSelectedGitDiff()
|
||||
}
|
||||
} catch (error) {
|
||||
showToastNotification({
|
||||
message: error instanceof Error ? error.message : `Failed to ${action} file`,
|
||||
variant: "error",
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const handleGitRowClick = (item: GitChangeListItem, event: MouseEvent) => {
|
||||
if (event.shiftKey) {
|
||||
event.preventDefault()
|
||||
const anchorId = gitBulkSelectionAnchorId() ?? item.id
|
||||
addGitBulkRange(anchorId, item.id)
|
||||
return
|
||||
}
|
||||
|
||||
if (event.ctrlKey || event.metaKey) {
|
||||
event.preventDefault()
|
||||
toggleGitBulkSelection(item.id)
|
||||
setGitBulkSelectionAnchorId(item.id)
|
||||
return
|
||||
}
|
||||
|
||||
clearGitBulkSelection()
|
||||
setGitBulkSelectionAnchorId(item.id)
|
||||
void openGitFile(item.id)
|
||||
}
|
||||
|
||||
const submitGitCommit = async () => {
|
||||
const message = gitCommitMessage().trim()
|
||||
if (!message || gitCommitSubmitting()) return
|
||||
|
||||
setGitCommitSubmitting(true)
|
||||
try {
|
||||
await serverApi.commitWorktreeGitChanges(options.instanceId, options.worktreeSlug(), { message })
|
||||
setGitCommitMessage("")
|
||||
await loadGitStatus(true)
|
||||
const nextSelection = resolveValidGitSelection(describeGitSelection(gitSelectedItemId()))
|
||||
setGitSelectedItemId(nextSelection)
|
||||
if (nextSelection) {
|
||||
await openGitFile(nextSelection)
|
||||
} else {
|
||||
clearSelectedGitDiff()
|
||||
}
|
||||
showToastNotification({
|
||||
message: options.t("instanceShell.gitChanges.commit.success"),
|
||||
variant: "success",
|
||||
})
|
||||
} catch (error) {
|
||||
showToastNotification({
|
||||
message: error instanceof Error ? error.message : options.t("instanceShell.gitChanges.commit.error"),
|
||||
variant: "error",
|
||||
})
|
||||
} finally {
|
||||
setGitCommitSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
const refreshGitStatus = async () => {
|
||||
await loadGitStatus(true)
|
||||
const selected = resolveValidGitSelection(describeGitSelection(gitSelectedItemId()))
|
||||
setGitSelectedItemId(selected)
|
||||
if (selected) {
|
||||
void openGitFile(selected)
|
||||
} else {
|
||||
clearSelectedGitDiff()
|
||||
}
|
||||
}
|
||||
|
||||
const insertGitChangeContext = (item: GitChangeListItem, selection: { startLine: number; endLine: number } | null) => {
|
||||
const startLine = selection?.startLine ?? 1
|
||||
const endLine = selection?.endLine ?? startLine
|
||||
options.promptInputApi()?.insertComment(`Git Diff: File: ${item.path} : ${startLine}-${endLine}`)
|
||||
}
|
||||
|
||||
createEffect(() => {
|
||||
options.worktreeSlug()
|
||||
gitStatusRequestVersion += 1
|
||||
gitDiffRequestVersion += 1
|
||||
passiveGitRefreshInFlight = false
|
||||
pendingGitPassiveRefreshOptions = null
|
||||
setGitStatusEntries(null)
|
||||
setGitStatusError(null)
|
||||
setGitStatusLoading(false)
|
||||
setGitSelectedItemId(null)
|
||||
clearGitBulkSelection()
|
||||
setGitSelectedLoading(false)
|
||||
clearSelectedGitDiff()
|
||||
setGitCommitMessage("")
|
||||
setGitCommitSubmitting(false)
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
if (options.rightPanelTab() !== "git-changes") return
|
||||
const items = gitListItems()
|
||||
if (gitStatusEntries() === null) return
|
||||
if (items.length === 0) return
|
||||
if (gitSelectedItemId()) return
|
||||
const next = gitMostChangedItemId()
|
||||
if (!next) return
|
||||
void openGitFile(next)
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
const activationKey = options.rightPanelTab() === "git-changes" ? `${options.instanceId}:${options.worktreeSlug()}` : null
|
||||
if (!activationKey) {
|
||||
previousGitChangesActivationKey = null
|
||||
return
|
||||
}
|
||||
if (previousGitChangesActivationKey === activationKey) return
|
||||
previousGitChangesActivationKey = activationKey
|
||||
void passiveRefreshGitStatus()
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
if (options.rightPanelTab() !== "git-changes") return
|
||||
|
||||
const unsubscribe = serverEvents.on("instance.event", (event) => {
|
||||
if (event.type !== "instance.event") return
|
||||
if (event.instanceId !== options.instanceId) return
|
||||
const eventType = (event.event as { type?: unknown } | undefined)?.type
|
||||
if (eventType !== "session.updated" && eventType !== "session.diff") return
|
||||
void passiveRefreshGitStatus({ forceReloadSelectedDiff: true })
|
||||
})
|
||||
|
||||
onCleanup(() => {
|
||||
unsubscribe()
|
||||
})
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
if (options.rightPanelTab() === "git-changes") return
|
||||
setGitSelectedBefore(null)
|
||||
setGitSelectedAfter(null)
|
||||
setGitSelectedLoading(false)
|
||||
setGitSelectedError(null)
|
||||
})
|
||||
|
||||
return {
|
||||
gitStatusEntries,
|
||||
gitStatusLoading,
|
||||
gitStatusError,
|
||||
gitSelectedItemId,
|
||||
gitBulkSelectedItemIds,
|
||||
gitSelectedLoading,
|
||||
gitSelectedError,
|
||||
gitSelectedBefore,
|
||||
gitSelectedAfter,
|
||||
gitCommitMessage,
|
||||
gitCommitSubmitting,
|
||||
gitMostChangedItemId,
|
||||
setGitCommitMessage,
|
||||
handleGitRowClick,
|
||||
refreshGitStatus,
|
||||
insertGitChangeContext,
|
||||
submitGitCommit,
|
||||
stageGitFile: (item: GitChangeListItem) => void mutateGitFile(item, "stage"),
|
||||
unstageGitFile: (item: GitChangeListItem) => void mutateGitFile(item, "unstage"),
|
||||
}
|
||||
}
|
||||
@@ -21,6 +21,10 @@ export const RIGHT_PANEL_FILES_LIST_OPEN_NONPHONE_KEY = "opencode-session-right-
|
||||
export const RIGHT_PANEL_FILES_LIST_OPEN_PHONE_KEY = "opencode-session-right-panel-files-list-open-phone-v1"
|
||||
export const RIGHT_PANEL_GIT_CHANGES_LIST_OPEN_NONPHONE_KEY = "opencode-session-right-panel-git-changes-list-open-nonphone-v1"
|
||||
export const RIGHT_PANEL_GIT_CHANGES_LIST_OPEN_PHONE_KEY = "opencode-session-right-panel-git-changes-list-open-phone-v1"
|
||||
export const RIGHT_PANEL_GIT_CHANGES_STAGED_OPEN_NONPHONE_KEY = "opencode-session-right-panel-git-changes-staged-open-nonphone-v1"
|
||||
export const RIGHT_PANEL_GIT_CHANGES_STAGED_OPEN_PHONE_KEY = "opencode-session-right-panel-git-changes-staged-open-phone-v1"
|
||||
export const RIGHT_PANEL_GIT_CHANGES_UNSTAGED_OPEN_NONPHONE_KEY = "opencode-session-right-panel-git-changes-unstaged-open-nonphone-v1"
|
||||
export const RIGHT_PANEL_GIT_CHANGES_UNSTAGED_OPEN_PHONE_KEY = "opencode-session-right-panel-git-changes-unstaged-open-phone-v1"
|
||||
export const RIGHT_PANEL_CHANGES_DIFF_VIEW_MODE_KEY = "opencode-session-right-panel-changes-diff-view-mode-v1"
|
||||
export const RIGHT_PANEL_CHANGES_DIFF_CONTEXT_MODE_KEY = "opencode-session-right-panel-changes-diff-context-mode-v1"
|
||||
export const RIGHT_PANEL_CHANGES_DIFF_WORD_WRAP_KEY = "opencode-session-right-panel-changes-diff-word-wrap-v1"
|
||||
|
||||
@@ -1344,9 +1344,7 @@ function ReasoningStreamOutput(props: {
|
||||
if (preRef && preRef.textContent !== nextText) {
|
||||
preRef.textContent = nextText
|
||||
}
|
||||
if (followScroll.autoScroll()) {
|
||||
followScroll.restoreAfterRender({ forceBottom: true })
|
||||
}
|
||||
followScroll.restoreAfterRender()
|
||||
notifyContentRendered()
|
||||
})
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Show, createEffect, createMemo, createSignal, onCleanup, on, untrack } from "solid-js"
|
||||
import { MoreHorizontal, Trash, X } from "lucide-solid"
|
||||
import { MoreHorizontal, Pause, Trash, X } from "lucide-solid"
|
||||
import Kbd from "./kbd"
|
||||
import MessageBlock from "./message-block"
|
||||
import { getMessageAnchorId, getMessageIdFromAnchorId } from "./message-anchors"
|
||||
@@ -16,12 +16,14 @@ import { showAlertDialog } from "../stores/alerts"
|
||||
import { deleteMessage, deleteMessagePart } from "../stores/session-actions"
|
||||
import type { InstanceMessageStore } from "../stores/message-v2/instance-store"
|
||||
import type { DeleteHoverState } from "../types/delete-hover"
|
||||
import { partHasRenderableText } from "../types/message"
|
||||
import { buildRecordDisplayData } from "../stores/message-v2/record-display-cache"
|
||||
import { getPartCharCount } from "../lib/token-utils"
|
||||
|
||||
const SCROLL_SENTINEL_MARGIN_PX = 8
|
||||
const MESSAGE_SCROLL_CACHE_SCOPE = "message-stream"
|
||||
const QUOTE_SELECTION_MAX_LENGTH = 2000
|
||||
const STREAMING_TEXT_HOLD_TOP_THRESHOLD_PX = 8
|
||||
const codeNomadLogo = new URL("../images/CodeNomad-Icon.png", import.meta.url).href
|
||||
|
||||
export interface MessageSectionProps {
|
||||
@@ -40,10 +42,11 @@ export interface MessageSectionProps {
|
||||
}
|
||||
|
||||
export default function MessageSection(props: MessageSectionProps) {
|
||||
const { preferences } = useConfig()
|
||||
const { preferences, updatePreferences } = useConfig()
|
||||
const { t } = useI18n()
|
||||
const showUsagePreference = () => preferences().showUsageMetrics ?? true
|
||||
const showTimelineToolsPreference = () => preferences().showTimelineTools ?? true
|
||||
const holdLongAssistantRepliesEnabled = () => preferences().holdLongAssistantReplies ?? true
|
||||
const store = createMemo<InstanceMessageStore>(() => messageStoreBus.getOrCreate(props.instanceId))
|
||||
const messageIds = createMemo(() => store().getSessionMessageIds(props.sessionId))
|
||||
const visibleMessageIds = createMemo(() => {
|
||||
@@ -594,7 +597,10 @@ export default function MessageSection(props: MessageSectionProps) {
|
||||
const [streamElement, setStreamElement] = createSignal<HTMLDivElement | undefined>()
|
||||
const [streamShellElement, setStreamShellElement] = createSignal<HTMLDivElement | undefined>()
|
||||
|
||||
const followToken = createMemo(() => `${sessionRevision()}|${preferenceSignature()}`)
|
||||
// Only preferences should force a follow-token re-anchor. Message/session
|
||||
// revision churn at the end of a turn (message.updated, session.idle, etc.)
|
||||
// should not trigger an immediate scroll-to-bottom.
|
||||
const followToken = createMemo(() => preferenceSignature())
|
||||
|
||||
const initialScrollSnapshot = createMemo(() => store().getScrollSnapshot(props.sessionId, MESSAGE_SCROLL_CACHE_SCOPE))
|
||||
const initialAutoScroll = createMemo(() => initialScrollSnapshot()?.atBottom ?? true)
|
||||
@@ -624,6 +630,35 @@ export default function MessageSection(props: MessageSectionProps) {
|
||||
|
||||
const [quoteSelection, setQuoteSelection] = createSignal<{ text: string; top: number; left: number } | null>(null)
|
||||
|
||||
const lastVisibleMessageId = createMemo(() => {
|
||||
const ids = visibleMessageIds()
|
||||
return ids[ids.length - 1] ?? null
|
||||
})
|
||||
|
||||
const autoPinHoldTargetKey = createMemo(() => {
|
||||
if (!holdLongAssistantRepliesEnabled()) return null
|
||||
const messageId = lastVisibleMessageId()
|
||||
return isAssistantTextMessage(messageId) ? messageId : null
|
||||
})
|
||||
|
||||
function toggleHoldLongAssistantReplies() {
|
||||
updatePreferences({ holdLongAssistantReplies: !holdLongAssistantRepliesEnabled() })
|
||||
}
|
||||
|
||||
function isAssistantTextMessage(messageId: string | null | undefined) {
|
||||
if (!messageId) return false
|
||||
const resolvedStore = store()
|
||||
const record = resolvedStore.getMessage(messageId)
|
||||
if (!record || record.role !== "assistant") return false
|
||||
|
||||
const { orderedParts } = buildRecordDisplayData(props.instanceId, record)
|
||||
return orderedParts.some((part) => {
|
||||
if ((part as any)?.type !== "text") return false
|
||||
if (partHasRenderableText(part)) return true
|
||||
return typeof (part as { text?: unknown }).text === "string"
|
||||
})
|
||||
}
|
||||
|
||||
createEffect(() => {
|
||||
const api = listApi()
|
||||
if (!api) return
|
||||
@@ -1044,6 +1079,12 @@ export default function MessageSection(props: MessageSectionProps) {
|
||||
initialAutoScroll={initialAutoScroll}
|
||||
resetKey={() => props.sessionId}
|
||||
followToken={followToken}
|
||||
autoPinHoldTargetKey={autoPinHoldTargetKey}
|
||||
autoPinHoldTopThresholdPx={STREAMING_TEXT_HOLD_TOP_THRESHOLD_PX}
|
||||
resolveAutoPinHoldElement={(itemWrapper, key) => {
|
||||
const candidates = Array.from(itemWrapper.querySelectorAll<HTMLElement>(`.message-item-base[data-message-id="${key}"][data-message-role="assistant"]`))
|
||||
return candidates[candidates.length - 1] ?? null
|
||||
}}
|
||||
onScroll={() => {
|
||||
clearQuoteSelection()
|
||||
scrollCache.persist(streamElement())
|
||||
@@ -1074,6 +1115,52 @@ export default function MessageSection(props: MessageSectionProps) {
|
||||
scrollToBottomAriaLabel={() => t("messageSection.scroll.toLatestAriaLabel")}
|
||||
registerApi={(api) => setListApi(api)}
|
||||
registerState={(state) => setListState(state)}
|
||||
renderControls={(state, api) => (
|
||||
<div class="message-scroll-button-wrapper">
|
||||
<button
|
||||
type="button"
|
||||
class="message-scroll-button"
|
||||
data-active={holdLongAssistantRepliesEnabled() ? "true" : "false"}
|
||||
onClick={toggleHoldLongAssistantReplies}
|
||||
aria-label={
|
||||
holdLongAssistantRepliesEnabled()
|
||||
? t("messageSection.scroll.disableHoldAriaLabel")
|
||||
: t("messageSection.scroll.enableHoldAriaLabel")
|
||||
}
|
||||
title={
|
||||
holdLongAssistantRepliesEnabled()
|
||||
? t("messageSection.scroll.disableHoldAriaLabel")
|
||||
: t("messageSection.scroll.enableHoldAriaLabel")
|
||||
}
|
||||
>
|
||||
<Pause class="message-scroll-icon message-scroll-icon--toggle w-4 h-4" aria-hidden="true" />
|
||||
</button>
|
||||
<Show when={state.showScrollTopButton()}>
|
||||
<button
|
||||
type="button"
|
||||
class="message-scroll-button"
|
||||
onClick={() => api.scrollToTop()}
|
||||
aria-label={t("messageSection.scroll.toFirstAriaLabel")}
|
||||
>
|
||||
<span class="message-scroll-icon" aria-hidden="true">
|
||||
↑
|
||||
</span>
|
||||
</button>
|
||||
</Show>
|
||||
<Show when={state.showScrollBottomButton()}>
|
||||
<button
|
||||
type="button"
|
||||
class="message-scroll-button"
|
||||
onClick={() => api.scrollToBottom()}
|
||||
aria-label={t("messageSection.scroll.toLatestAriaLabel")}
|
||||
>
|
||||
<span class="message-scroll-icon" aria-hidden="true">
|
||||
↓
|
||||
</span>
|
||||
</button>
|
||||
</Show>
|
||||
</div>
|
||||
)}
|
||||
renderBeforeItems={() => (
|
||||
<>
|
||||
<Show when={!props.loading && visibleMessageIds().length === 0}>
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { For, Show, createEffect, createMemo, createSignal, onCleanup, on, untrack, type Component, type Accessor } from "solid-js"
|
||||
import { Virtualizer, type VirtualizerHandle } from "virtua/solid"
|
||||
import { Portal } from "solid-js/web"
|
||||
import MessagePreview from "./message-preview"
|
||||
import { messageStoreBus } from "../stores/message-v2/bus"
|
||||
import type { ClientPart } from "../types/message"
|
||||
@@ -54,6 +56,7 @@ const MAX_TOOLTIP_LENGTH = 220
|
||||
const LONG_PRESS_MS = 500
|
||||
const JITTER_THRESHOLD = 10
|
||||
const ABSOLUTE_TOKEN_CAP = 10000
|
||||
const TIMELINE_VIRTUALIZER_BUFFER_PX = 240
|
||||
|
||||
type ToolCallPart = Extract<ClientPart, { type: "tool" }>
|
||||
|
||||
@@ -66,6 +69,13 @@ interface PendingSegment {
|
||||
hasPrimaryText: boolean
|
||||
}
|
||||
|
||||
interface TimelineSegmentState {
|
||||
deleteHovered: boolean
|
||||
deleteSelected: boolean
|
||||
hasActivePermission: boolean
|
||||
hidden: boolean
|
||||
}
|
||||
|
||||
function truncateText(value: string): string {
|
||||
if (value.length <= MAX_TOOLTIP_LENGTH) {
|
||||
return value
|
||||
@@ -351,6 +361,13 @@ const MessageTimeline: Component<MessageTimelineProps> = (props) => {
|
||||
}
|
||||
}
|
||||
|
||||
const clearHoverPreview = () => {
|
||||
clearHoverTimer()
|
||||
clearCloseTimer()
|
||||
setHoveredSegment(null)
|
||||
setHoverAnchorRect(null)
|
||||
}
|
||||
|
||||
const scheduleClose = () => {
|
||||
if (typeof window === "undefined") return
|
||||
clearHoverTimer()
|
||||
@@ -358,8 +375,7 @@ const MessageTimeline: Component<MessageTimelineProps> = (props) => {
|
||||
// Small delay so the pointer can travel from the segment to the tooltip.
|
||||
closeTimer = window.setTimeout(() => {
|
||||
closeTimer = null
|
||||
setHoveredSegment(null)
|
||||
setHoverAnchorRect(null)
|
||||
clearHoverPreview()
|
||||
}, 160)
|
||||
}
|
||||
|
||||
@@ -399,8 +415,7 @@ const MessageTimeline: Component<MessageTimelineProps> = (props) => {
|
||||
})
|
||||
|
||||
onCleanup(() => {
|
||||
clearHoverTimer()
|
||||
clearCloseTimer()
|
||||
clearHoverPreview()
|
||||
})
|
||||
|
||||
// --- Selection & histogram rib state ---
|
||||
@@ -418,6 +433,8 @@ const MessageTimeline: Component<MessageTimelineProps> = (props) => {
|
||||
// on activation, resize, or expansion — NOT on every scroll frame.
|
||||
const [badgeOffsets, setBadgeOffsets] = createSignal<Record<string, { layoutTop: number; height: number }>>({})
|
||||
const [windowWidth, setWindowWidth] = createSignal(typeof window !== "undefined" ? window.innerWidth : 1200)
|
||||
const [scrollElement, setScrollElement] = createSignal<HTMLDivElement | undefined>()
|
||||
const [virtualizerHandle, setVirtualizerHandle] = createSignal<VirtualizerHandle | undefined>()
|
||||
let scrollContainerRef: HTMLDivElement | undefined
|
||||
let xrayOverlayRef: HTMLDivElement | undefined
|
||||
|
||||
@@ -449,6 +466,12 @@ const MessageTimeline: Component<MessageTimelineProps> = (props) => {
|
||||
}
|
||||
|
||||
const handleScroll = () => {
|
||||
if (renderVirtualizedTimeline()) {
|
||||
if (hoveredSegment()) {
|
||||
clearHoverPreview()
|
||||
}
|
||||
return
|
||||
}
|
||||
if (!isSelectionActive()) return
|
||||
if (!scrollContainerRef || !xrayOverlayRef) return
|
||||
xrayOverlayRef.style.setProperty("--xray-scroll-y", `${-scrollContainerRef.scrollTop}px`)
|
||||
@@ -477,6 +500,12 @@ const MessageTimeline: Component<MessageTimelineProps> = (props) => {
|
||||
}
|
||||
})
|
||||
|
||||
const renderVirtualizedTimeline = createMemo(() => !isSelectionActive())
|
||||
|
||||
createEffect(on(renderVirtualizedTimeline, () => {
|
||||
clearHoverPreview()
|
||||
}))
|
||||
|
||||
const maxRibWidth = createMemo(() => Math.round(windowWidth() * 0.5))
|
||||
|
||||
// Compute fresh char counts from the store. segment.totalChars can be stale for
|
||||
@@ -579,7 +608,7 @@ const MessageTimeline: Component<MessageTimelineProps> = (props) => {
|
||||
wasLongPress = true
|
||||
|
||||
// Scroll anchoring: preserve visual position of the pressed badge.
|
||||
const btn = buttonRefs.get(segment.id)
|
||||
const btn = renderVirtualizedTimeline() ? null : buttonRefs.get(segment.id)
|
||||
let anchorOffset: number | null = null
|
||||
if (btn && scrollContainerRef) {
|
||||
anchorOffset = btn.offsetTop - scrollContainerRef.scrollTop
|
||||
@@ -631,9 +660,17 @@ const MessageTimeline: Component<MessageTimelineProps> = (props) => {
|
||||
|
||||
createEffect(on(() => props.activeSegmentId, (activeId) => {
|
||||
if (!activeId) return
|
||||
const element = buttonRefs.get(activeId)
|
||||
if (!element) return
|
||||
const timer = typeof window !== "undefined" ? window.setTimeout(() => {
|
||||
if (renderVirtualizedTimeline()) {
|
||||
const index = segmentIndexById().get(activeId)
|
||||
if (index !== undefined) {
|
||||
virtualizerHandle()?.scrollToIndex(index, { align: "nearest", smooth: true })
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
const element = buttonRefs.get(activeId)
|
||||
if (!element) return
|
||||
element.scrollIntoView({ block: "nearest", behavior: "smooth" })
|
||||
}, 120) : null
|
||||
onCleanup(() => {
|
||||
@@ -684,60 +721,239 @@ const MessageTimeline: Component<MessageTimelineProps> = (props) => {
|
||||
return map
|
||||
})
|
||||
|
||||
const segmentIndexById = createMemo(() => {
|
||||
const map = new Map<string, number>()
|
||||
for (let i = 0; i < props.segments.length; i++) map.set(props.segments[i].id, i)
|
||||
return map
|
||||
})
|
||||
|
||||
const segmentStates = createMemo(() => {
|
||||
const hover = deleteHover()
|
||||
const selectedMessages = props.selectedMessageIds?.()
|
||||
const expandedMessages = props.expandedMessageIds?.()
|
||||
const resolvedStore = store()
|
||||
const indexMap = messageIdToSessionIndex()
|
||||
const selectionActive = isSelectionActive()
|
||||
const result = new Map<string, TimelineSegmentState>()
|
||||
|
||||
for (const segment of props.segments) {
|
||||
let deleteHovered = false
|
||||
if (hover.kind === "message") {
|
||||
deleteHovered = hover.messageId === segment.messageId
|
||||
} else if (hover.kind === "deleteUpTo") {
|
||||
const targetIndex = indexMap.get(hover.messageId)
|
||||
const segmentIndex = indexMap.get(segment.messageId)
|
||||
deleteHovered = targetIndex !== undefined && segmentIndex !== undefined && segmentIndex >= targetIndex
|
||||
}
|
||||
|
||||
const deleteSelected = selectedMessages?.has(segment.messageId) ?? false
|
||||
|
||||
let hasActivePermission = false
|
||||
if (segment.type === "tool") {
|
||||
const partIds = segment.toolPartIds ?? []
|
||||
for (const partId of partIds) {
|
||||
const permissionState = resolvedStore.getPermissionState(segment.messageId, partId)
|
||||
if (permissionState?.active) {
|
||||
hasActivePermission = true
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const hidden = segment.type === "tool" && !(
|
||||
showTools()
|
||||
|| expandedMessages?.has(segment.messageId)
|
||||
|| selectionActive
|
||||
|| props.activeSegmentId === segment.id
|
||||
|| hasActivePermission
|
||||
|| deleteHovered
|
||||
|| deleteSelected
|
||||
)
|
||||
|
||||
result.set(segment.id, {
|
||||
deleteHovered,
|
||||
deleteSelected,
|
||||
hasActivePermission,
|
||||
hidden,
|
||||
})
|
||||
}
|
||||
|
||||
return result
|
||||
})
|
||||
|
||||
const segmentStateFor = (segmentId: string): TimelineSegmentState => {
|
||||
return segmentStates().get(segmentId) ?? {
|
||||
deleteHovered: false,
|
||||
deleteSelected: false,
|
||||
hasActivePermission: false,
|
||||
hidden: false,
|
||||
}
|
||||
}
|
||||
|
||||
const segmentSpacerHeights = createMemo(() => {
|
||||
const states = segmentStates()
|
||||
const result = new Map<string, string>()
|
||||
let previousVisible: TimelineSegment | null = null
|
||||
|
||||
for (let index = 0; index < props.segments.length; index += 1) {
|
||||
const segment = props.segments[index]
|
||||
const state = states.get(segment.id)
|
||||
|
||||
if (state?.hidden) {
|
||||
result.set(segment.id, "0")
|
||||
continue
|
||||
}
|
||||
|
||||
if (!previousVisible) {
|
||||
result.set(segment.id, "0")
|
||||
previousVisible = segment
|
||||
continue
|
||||
}
|
||||
|
||||
const previousRaw = index > 0 ? props.segments[index - 1] : null
|
||||
const startsVisibleToolGroup = segment.type === "tool"
|
||||
&& (previousVisible.type !== "tool" || previousVisible.messageId !== segment.messageId)
|
||||
const startsCollapsedToolGroup = segment.type === "assistant"
|
||||
&& previousVisible.messageId !== segment.messageId
|
||||
&& messagesWithTools().has(segment.messageId)
|
||||
&& previousRaw?.type === "tool"
|
||||
&& previousRaw.messageId === segment.messageId
|
||||
const followsVisibleGroupParent = (segment.type === "user" || segment.type === "compaction")
|
||||
&& previousVisible.type === "assistant"
|
||||
&& messagesWithTools().has(previousVisible.messageId)
|
||||
|
||||
const gapUnits = 1 + (startsVisibleToolGroup || startsCollapsedToolGroup || followsVisibleGroupParent ? 1 : 0)
|
||||
result.set(
|
||||
segment.id,
|
||||
gapUnits === 1
|
||||
? "var(--message-timeline-segment-gap)"
|
||||
: "calc(var(--message-timeline-segment-gap) * 2)",
|
||||
)
|
||||
|
||||
previousVisible = segment
|
||||
}
|
||||
|
||||
return result
|
||||
})
|
||||
|
||||
return (
|
||||
<div class="message-timeline-container">
|
||||
<div
|
||||
ref={scrollContainerRef}
|
||||
ref={(element) => {
|
||||
scrollContainerRef = element
|
||||
setScrollElement(element)
|
||||
}}
|
||||
class={`message-timeline${isSelectionActive() ? " message-timeline--selection-active" : ""}`}
|
||||
role="navigation"
|
||||
aria-label={t("messageTimeline.ariaLabel")}
|
||||
onScroll={handleScroll}
|
||||
>
|
||||
<For each={props.segments}>
|
||||
{(segment, segIndex) => {
|
||||
onCleanup(() => buttonRefs.delete(segment.id))
|
||||
<Show
|
||||
when={renderVirtualizedTimeline()}
|
||||
fallback={(
|
||||
<For each={props.segments}>
|
||||
{(segment, segIndex) => {
|
||||
onCleanup(() => buttonRefs.delete(segment.id))
|
||||
const isActive = () => props.activeSegmentId === segment.id
|
||||
const isSelected = () => props.selectedIds?.().has(segment.id)
|
||||
const state = () => segmentStateFor(segment.id)
|
||||
const isDeleteHovered = () => state().deleteHovered
|
||||
const isDeleteSelected = () => state().deleteSelected
|
||||
const hasActivePermission = () => state().hasActivePermission
|
||||
const isHidden = () => state().hidden
|
||||
|
||||
const groupRole = (): "child" | "parent" | "none" => {
|
||||
if (segment.type === "tool") return "child"
|
||||
if (segment.type === "assistant" && messagesWithTools().has(segment.messageId)) return "parent"
|
||||
return "none"
|
||||
}
|
||||
|
||||
const shortLabelContent = () => {
|
||||
if (segment.type === "tool") {
|
||||
if (hasActivePermission()) {
|
||||
return <ShieldAlert class="message-timeline-icon" aria-hidden="true" />
|
||||
}
|
||||
return segment.shortLabel ?? getToolIcon("tool")
|
||||
}
|
||||
if (segment.type === "compaction") {
|
||||
return <FoldVertical class="message-timeline-icon" aria-hidden="true" />
|
||||
}
|
||||
if (segment.type === "user") {
|
||||
return <UserIcon class="message-timeline-icon" aria-hidden="true" />
|
||||
}
|
||||
return <BotIcon class="message-timeline-icon" aria-hidden="true" />
|
||||
}
|
||||
|
||||
return (
|
||||
<div class="message-timeline-item">
|
||||
<div aria-hidden="true" class="message-timeline-item-spacer" style={{ height: segmentSpacerHeights().get(segment.id) ?? "0" }} />
|
||||
<button
|
||||
ref={(el) => registerButtonRef(segment.id, el)}
|
||||
type="button"
|
||||
data-variant={segment.variant}
|
||||
class={`message-timeline-segment message-timeline-${segment.type} ${hasActivePermission() ? "message-timeline-segment-permission" : ""} ${segment.type === "compaction" ? `message-timeline-compaction-${segment.variant ?? "manual"}` : ""} ${isActive() ? "message-timeline-segment-active" : ""} ${isHidden() ? "message-timeline-segment-hidden" : ""} ${isSelected() ? "message-timeline-segment-selected" : ""} ${isDeleteSelected() ? "message-timeline-segment-delete-selected" : ""} ${groupRole() !== "none" ? `message-timeline-group-${groupRole()}` : ""}`}
|
||||
data-delete-hover={isDeleteHovered() || isDeleteSelected() || isSelected() ? "true" : undefined}
|
||||
aria-current={isActive() ? "true" : undefined}
|
||||
aria-hidden={isHidden() ? "true" : undefined}
|
||||
onClick={(event) => {
|
||||
if (wasLongPress) {
|
||||
wasLongPress = false
|
||||
return
|
||||
}
|
||||
|
||||
const btn = buttonRefs.get(segment.id)
|
||||
const stableBtn = renderVirtualizedTimeline() ? null : btn
|
||||
let anchorOffset: number | null = null
|
||||
if (stableBtn && scrollContainerRef) {
|
||||
anchorOffset = stableBtn.offsetTop - scrollContainerRef.scrollTop
|
||||
}
|
||||
|
||||
const isMultiSelectActive = (props.selectedIds?.().size ?? 0) > 0
|
||||
|
||||
if (event.shiftKey) {
|
||||
props.onSelectRange?.(segment.id)
|
||||
} else if (event.ctrlKey || event.metaKey) {
|
||||
props.onToggleSelection?.(segment.id)
|
||||
} else if (isMultiSelectActive) {
|
||||
props.onSegmentClick?.(segment)
|
||||
} else {
|
||||
props.onSegmentClick?.(segment)
|
||||
}
|
||||
|
||||
if (anchorOffset !== null && stableBtn && scrollContainerRef) {
|
||||
const desired = stableBtn.offsetTop - anchorOffset
|
||||
if (Math.abs(scrollContainerRef.scrollTop - desired) > 1) {
|
||||
scrollContainerRef.scrollTop = desired
|
||||
}
|
||||
}
|
||||
}}
|
||||
onPointerDown={(e) => handlePointerDown(segment, e)}
|
||||
onPointerUp={handlePointerUp}
|
||||
onPointerCancel={handlePointerUp}
|
||||
onPointerMove={handlePointerMove}
|
||||
onContextMenu={handleContextMenu}
|
||||
onMouseEnter={(event) => handleMouseEnter(segment, event)}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
>
|
||||
<span class="message-timeline-label message-timeline-label-full">{segment.label}</span>
|
||||
<span class="message-timeline-label message-timeline-label-short">{shortLabelContent()}</span>
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}}
|
||||
</For>
|
||||
)}
|
||||
>
|
||||
<Virtualizer ref={setVirtualizerHandle} data={props.segments} scrollRef={scrollElement()} bufferSize={TIMELINE_VIRTUALIZER_BUFFER_PX}>
|
||||
{(segment, index) => {
|
||||
const segIndex = () => index()
|
||||
const isActive = () => props.activeSegmentId === segment.id
|
||||
const isSelected = () => props.selectedIds?.().has(segment.id)
|
||||
|
||||
const isDeleteHovered = () => {
|
||||
const hover = deleteHover() as DeleteHoverState
|
||||
if (hover.kind === "message") {
|
||||
return hover.messageId === segment.messageId
|
||||
}
|
||||
|
||||
if (hover.kind === "deleteUpTo") {
|
||||
const indexMap = messageIdToSessionIndex()
|
||||
const targetIndex = indexMap.get(hover.messageId)
|
||||
if (targetIndex === undefined) return false
|
||||
const segmentIndex = indexMap.get(segment.messageId)
|
||||
if (segmentIndex === undefined) return false
|
||||
return segmentIndex >= targetIndex
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
const isDeleteSelected = () => {
|
||||
const selected = props.selectedMessageIds?.()
|
||||
if (!selected) return false
|
||||
return selected.has(segment.messageId)
|
||||
}
|
||||
|
||||
const hasActivePermission = () => {
|
||||
if (segment.type !== "tool") return false
|
||||
const partIds = segment.toolPartIds ?? []
|
||||
if (partIds.length === 0) return false
|
||||
for (const partId of partIds) {
|
||||
const permissionState = store().getPermissionState(segment.messageId, partId)
|
||||
if (permissionState?.active) return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
const isExpanded = () => props.expandedMessageIds?.().has(segment.messageId) ?? false
|
||||
const isHidden = () =>
|
||||
segment.type === "tool" &&
|
||||
!(showTools() || isExpanded() || isSelectionActive() || isActive() || hasActivePermission() || isDeleteHovered() || isDeleteSelected())
|
||||
const state = () => segmentStateFor(segment.id)
|
||||
const isDeleteHovered = () => state().deleteHovered
|
||||
const isDeleteSelected = () => state().deleteSelected
|
||||
const hasActivePermission = () => state().hasActivePermission
|
||||
const isHidden = () => state().hidden
|
||||
|
||||
// Group visual indicators: tools belong to the same message as their
|
||||
// assistant. Uses messageId for correctness (not positional adjacency).
|
||||
@@ -746,18 +962,10 @@ const MessageTimeline: Component<MessageTimelineProps> = (props) => {
|
||||
if (segment.type === "assistant" && messagesWithTools().has(segment.messageId)) return "parent"
|
||||
return "none"
|
||||
}
|
||||
const isGroupStart = () => {
|
||||
if (segment.type !== "tool") return false
|
||||
const idx = segIndex()
|
||||
const prev = idx > 0 ? props.segments[idx - 1] : null
|
||||
// First tool in the message's run: either nothing before, or previous
|
||||
// segment is from a different message or is not a tool.
|
||||
return !prev || prev.type !== "tool" || prev.messageId !== segment.messageId
|
||||
}
|
||||
|
||||
const shortLabelContent = () => {
|
||||
if (segment.type === "tool") {
|
||||
if (hasActivePermission()) {
|
||||
if (hasActivePermission()) {
|
||||
return <ShieldAlert class="message-timeline-icon" aria-hidden="true" />
|
||||
}
|
||||
return segment.shortLabel ?? getToolIcon("tool")
|
||||
@@ -767,95 +975,92 @@ const MessageTimeline: Component<MessageTimelineProps> = (props) => {
|
||||
}
|
||||
if (segment.type === "user") {
|
||||
return <UserIcon class="message-timeline-icon" aria-hidden="true" />
|
||||
}
|
||||
return <BotIcon class="message-timeline-icon" aria-hidden="true" />
|
||||
}
|
||||
}
|
||||
return <BotIcon class="message-timeline-icon" aria-hidden="true" />
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
ref={(el) => registerButtonRef(segment.id, el)}
|
||||
type="button"
|
||||
data-variant={segment.variant}
|
||||
class={`message-timeline-segment message-timeline-${segment.type} ${hasActivePermission() ? "message-timeline-segment-permission" : ""} ${segment.type === "compaction" ? `message-timeline-compaction-${segment.variant ?? "manual"}` : ""} ${isActive() ? "message-timeline-segment-active" : ""} ${isHidden() ? "message-timeline-segment-hidden" : ""} ${isSelected() ? "message-timeline-segment-selected" : ""} ${isDeleteSelected() ? "message-timeline-segment-delete-selected" : ""} ${groupRole() !== "none" ? `message-timeline-group-${groupRole()}` : ""} ${isGroupStart() ? "message-timeline-group-start" : ""}`}
|
||||
|
||||
data-delete-hover={isDeleteHovered() || isDeleteSelected() || isSelected() ? "true" : undefined}
|
||||
|
||||
aria-current={isActive() ? "true" : undefined}
|
||||
aria-hidden={isHidden() ? "true" : undefined}
|
||||
onClick={(event) => {
|
||||
if (wasLongPress) {
|
||||
wasLongPress = false
|
||||
return
|
||||
}
|
||||
|
||||
// Capture scroll anchor before selection changes may toggle
|
||||
// tool segment visibility, which shifts timeline layout.
|
||||
const btn = buttonRefs.get(segment.id)
|
||||
let anchorOffset: number | null = null
|
||||
if (btn && scrollContainerRef) {
|
||||
anchorOffset = btn.offsetTop - scrollContainerRef.scrollTop
|
||||
}
|
||||
|
||||
const isMultiSelectActive = (props.selectedIds?.().size ?? 0) > 0
|
||||
|
||||
if (event.shiftKey) {
|
||||
props.onSelectRange?.(segment.id)
|
||||
} else if (event.ctrlKey || event.metaKey) {
|
||||
props.onToggleSelection?.(segment.id)
|
||||
} else if (isMultiSelectActive) {
|
||||
// In selection mode, plain click scrolls to the message
|
||||
// instead of clearing. Selection is cleared by clicking
|
||||
// anywhere inside the chat container or pressing Esc.
|
||||
props.onSegmentClick?.(segment)
|
||||
} else {
|
||||
props.onSegmentClick?.(segment)
|
||||
}
|
||||
|
||||
// Restore scroll anchor: keep the clicked badge at the same
|
||||
// visual position after hidden tools appear or disappear.
|
||||
if (anchorOffset !== null && btn && scrollContainerRef) {
|
||||
const desired = btn.offsetTop - anchorOffset
|
||||
if (Math.abs(scrollContainerRef.scrollTop - desired) > 1) {
|
||||
scrollContainerRef.scrollTop = desired
|
||||
return (
|
||||
<div class="message-timeline-item">
|
||||
<div aria-hidden="true" class="message-timeline-item-spacer" style={{ height: segmentSpacerHeights().get(segment.id) ?? "0" }} />
|
||||
<button
|
||||
type="button"
|
||||
data-variant={segment.variant}
|
||||
class={`message-timeline-segment message-timeline-${segment.type} ${hasActivePermission() ? "message-timeline-segment-permission" : ""} ${segment.type === "compaction" ? `message-timeline-compaction-${segment.variant ?? "manual"}` : ""} ${isActive() ? "message-timeline-segment-active" : ""} ${isHidden() ? "message-timeline-segment-hidden" : ""} ${isSelected() ? "message-timeline-segment-selected" : ""} ${isDeleteSelected() ? "message-timeline-segment-delete-selected" : ""} ${groupRole() !== "none" ? `message-timeline-group-${groupRole()}` : ""}`}
|
||||
data-delete-hover={isDeleteHovered() || isDeleteSelected() || isSelected() ? "true" : undefined}
|
||||
aria-current={isActive() ? "true" : undefined}
|
||||
aria-hidden={isHidden() ? "true" : undefined}
|
||||
onClick={(event) => {
|
||||
if (wasLongPress) {
|
||||
wasLongPress = false
|
||||
return
|
||||
}
|
||||
}
|
||||
}}
|
||||
onPointerDown={(e) => handlePointerDown(segment, e)}
|
||||
onPointerUp={handlePointerUp}
|
||||
onPointerCancel={handlePointerUp}
|
||||
onPointerMove={handlePointerMove}
|
||||
onContextMenu={handleContextMenu}
|
||||
onMouseEnter={(event) => handleMouseEnter(segment, event)}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
>
|
||||
<span class="message-timeline-label message-timeline-label-full">{segment.label}</span>
|
||||
<span class="message-timeline-label message-timeline-label-short">{shortLabelContent()}</span>
|
||||
</button>
|
||||
)
|
||||
}}
|
||||
</For>
|
||||
|
||||
const btn = buttonRefs.get(segment.id)
|
||||
const stableBtn = renderVirtualizedTimeline() ? null : btn
|
||||
let anchorOffset: number | null = null
|
||||
if (stableBtn && scrollContainerRef) {
|
||||
anchorOffset = stableBtn.offsetTop - scrollContainerRef.scrollTop
|
||||
}
|
||||
|
||||
const isMultiSelectActive = (props.selectedIds?.().size ?? 0) > 0
|
||||
|
||||
if (event.shiftKey) {
|
||||
props.onSelectRange?.(segment.id)
|
||||
} else if (event.ctrlKey || event.metaKey) {
|
||||
props.onToggleSelection?.(segment.id)
|
||||
} else if (isMultiSelectActive) {
|
||||
props.onSegmentClick?.(segment)
|
||||
} else {
|
||||
props.onSegmentClick?.(segment)
|
||||
}
|
||||
|
||||
if (anchorOffset !== null && stableBtn && scrollContainerRef) {
|
||||
const desired = stableBtn.offsetTop - anchorOffset
|
||||
if (Math.abs(scrollContainerRef.scrollTop - desired) > 1) {
|
||||
scrollContainerRef.scrollTop = desired
|
||||
}
|
||||
}
|
||||
}}
|
||||
onPointerDown={(e) => handlePointerDown(segment, e)}
|
||||
onPointerUp={handlePointerUp}
|
||||
onPointerCancel={handlePointerUp}
|
||||
onPointerMove={handlePointerMove}
|
||||
onContextMenu={handleContextMenu}
|
||||
onMouseEnter={(event) => handleMouseEnter(segment, event)}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
>
|
||||
<span class="message-timeline-label message-timeline-label-full">{segment.label}</span>
|
||||
<span class="message-timeline-label message-timeline-label-short">{shortLabelContent()}</span>
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}}
|
||||
</Virtualizer>
|
||||
</Show>
|
||||
<Show when={previewData()}>
|
||||
{(data) => {
|
||||
onCleanup(() => setTooltipElement(null))
|
||||
return (
|
||||
<div
|
||||
ref={(element) => setTooltipElement(element)}
|
||||
class="message-timeline-tooltip"
|
||||
style={{ top: `${tooltipCoords().top}px`, left: `${tooltipCoords().left}px` }}
|
||||
onMouseEnter={() => clearCloseTimer()}
|
||||
onMouseLeave={() => scheduleClose()}
|
||||
>
|
||||
<MessagePreview
|
||||
messageId={data().messageId}
|
||||
instanceId={props.instanceId}
|
||||
sessionId={props.sessionId}
|
||||
store={store}
|
||||
deleteHover={props.deleteHover}
|
||||
onDeleteHoverChange={props.onDeleteHoverChange}
|
||||
onDeleteMessagesUpTo={props.onDeleteMessagesUpTo}
|
||||
selectedMessageIds={props.selectedMessageIds}
|
||||
/>
|
||||
</div>
|
||||
<Portal>
|
||||
<div
|
||||
ref={(element) => setTooltipElement(element)}
|
||||
class="message-timeline-tooltip"
|
||||
style={{ top: `${tooltipCoords().top}px`, left: `${tooltipCoords().left}px` }}
|
||||
onMouseEnter={() => clearCloseTimer()}
|
||||
onMouseLeave={() => scheduleClose()}
|
||||
>
|
||||
<MessagePreview
|
||||
messageId={data().messageId}
|
||||
instanceId={props.instanceId}
|
||||
sessionId={props.sessionId}
|
||||
store={store}
|
||||
deleteHover={props.deleteHover}
|
||||
onDeleteHoverChange={props.onDeleteHoverChange}
|
||||
onDeleteMessagesUpTo={props.onDeleteMessagesUpTo}
|
||||
selectedMessageIds={props.selectedMessageIds}
|
||||
/>
|
||||
</div>
|
||||
</Portal>
|
||||
)
|
||||
}}
|
||||
</Show>
|
||||
|
||||
@@ -120,6 +120,11 @@ export default function PromptInput(props: PromptInputProps) {
|
||||
insertQuotedSelection(text)
|
||||
}
|
||||
},
|
||||
insertComment: (text: string) => {
|
||||
const normalized = (text ?? "").replace(/\r/g, "").trim()
|
||||
if (!normalized) return
|
||||
insertBlockContent(`${normalized}\n\n`)
|
||||
},
|
||||
expandTextAttachment: (attachmentId: string) => {
|
||||
const attachment = attachments().find((a) => a.id === attachmentId)
|
||||
if (!attachment) return
|
||||
|
||||
@@ -7,6 +7,7 @@ export type PromptInsertMode = "quote" | "code"
|
||||
|
||||
export interface PromptInputApi {
|
||||
insertSelection(text: string, mode: PromptInsertMode): void
|
||||
insertComment(text: string): void
|
||||
expandTextAttachment(attachmentId: string): void
|
||||
removeAttachment(attachmentId: string): void
|
||||
setPromptText(text: string, opts?: { focus?: boolean }): void
|
||||
|
||||
@@ -176,7 +176,7 @@ export function usePromptVoiceInput(options: UsePromptVoiceInputOptions) {
|
||||
const before = current.slice(0, start)
|
||||
const after = current.slice(end)
|
||||
const prefix = ""
|
||||
const suffix = after.length > 0 && !after.startsWith("\n") ? "\n" : ""
|
||||
const suffix = after.length > 0 ? (/^\s/.test(after) ? "" : " ") : " "
|
||||
const nextValue = `${before}${prefix}${text}${suffix}${after}`
|
||||
const cursor = before.length + prefix.length + text.length + suffix.length
|
||||
|
||||
|
||||
@@ -36,6 +36,7 @@ interface SessionViewProps {
|
||||
onSidebarToggle?: () => void
|
||||
forceCompactStatusLayout?: boolean
|
||||
isActive?: boolean
|
||||
registerSessionPromptApi?: (sessionId: string, api: PromptInputApi | null) => void
|
||||
}
|
||||
|
||||
export const SessionView: Component<SessionViewProps> = (props) => {
|
||||
@@ -79,11 +80,17 @@ export const SessionView: Component<SessionViewProps> = (props) => {
|
||||
requestAnimationFrame(() => scrollToBottomHandle?.())
|
||||
})
|
||||
}
|
||||
createEffect(() => {
|
||||
if (!props.isActive) return
|
||||
if (!shouldScrollToBottomOnActivate()) return
|
||||
scheduleScrollToBottom()
|
||||
})
|
||||
createEffect(
|
||||
on(
|
||||
() => props.isActive,
|
||||
(isActive, wasActive) => {
|
||||
if (!isActive) return
|
||||
if (wasActive === true) return
|
||||
if (!shouldScrollToBottomOnActivate()) return
|
||||
scheduleScrollToBottom()
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
createEffect(
|
||||
on(
|
||||
@@ -143,6 +150,7 @@ export const SessionView: Component<SessionViewProps> = (props) => {
|
||||
|
||||
function registerPromptInputApi(api: PromptInputApi) {
|
||||
promptInputApi = api
|
||||
props.registerSessionPromptApi?.(props.sessionId, api)
|
||||
|
||||
if (pendingPromptText) {
|
||||
api.setPromptText(pendingPromptText, { focus: true })
|
||||
@@ -157,6 +165,7 @@ export const SessionView: Component<SessionViewProps> = (props) => {
|
||||
return () => {
|
||||
if (promptInputApi === api) {
|
||||
promptInputApi = null
|
||||
props.registerSessionPromptApi?.(props.sessionId, null)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -332,16 +341,11 @@ export const SessionView: Component<SessionViewProps> = (props) => {
|
||||
loading={messagesLoading()}
|
||||
onRevert={handleRevert}
|
||||
onDeleteMessagesUpTo={handleDeleteMessagesUpTo}
|
||||
onFork={handleFork}
|
||||
isActive={props.isActive}
|
||||
registerScrollToBottom={(fn) => {
|
||||
scrollToBottomHandle = fn
|
||||
if (props.isActive) {
|
||||
if (shouldScrollToBottomOnActivate()) {
|
||||
scheduleScrollToBottom()
|
||||
}
|
||||
}
|
||||
}}
|
||||
onFork={handleFork}
|
||||
isActive={props.isActive}
|
||||
registerScrollToBottom={(fn) => {
|
||||
scrollToBottomHandle = fn
|
||||
}}
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -454,7 +454,7 @@ function ToolCallDetails(props: {
|
||||
|
||||
createEffect(() => {
|
||||
if (followScroll.autoScroll()) {
|
||||
scrollHelpers.restoreAfterRender({ forceBottom: true })
|
||||
scrollHelpers.restoreAfterRender()
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -47,7 +47,7 @@ export interface ToolScrollHelpers {
|
||||
registerContainer(element: HTMLDivElement | null, options?: { disableTracking?: boolean }): void
|
||||
handleScroll(event: Event & { currentTarget: HTMLDivElement }): void
|
||||
renderSentinel(options?: { disableTracking?: boolean }): JSXElement | null
|
||||
restoreAfterRender(options?: { forceBottom?: boolean }): void
|
||||
restoreAfterRender(): void
|
||||
}
|
||||
|
||||
export interface ToolRendererContext {
|
||||
|
||||
@@ -2,6 +2,8 @@ import { Show, createEffect, createMemo, createSignal, onCleanup, type Accessor,
|
||||
import { Virtualizer, type VirtualizerHandle } from "virtua/solid"
|
||||
|
||||
const DEFAULT_SCROLL_SENTINEL_MARGIN_PX = 48
|
||||
const DEFAULT_HOLD_TARGET_TOP_THRESHOLD_PX = 8
|
||||
const DEFAULT_HOLD_TARGET_TOP_OVERSHOOT_PX = 128
|
||||
const USER_SCROLL_INTENT_WINDOW_MS = 600
|
||||
const SCROLL_INTENT_KEYS = new Set(["ArrowUp", "ArrowDown", "PageUp", "PageDown", "Home", "End", " ", "Spacebar"])
|
||||
|
||||
@@ -85,6 +87,28 @@ export interface VirtualFollowListProps<T> {
|
||||
*/
|
||||
followToken?: Accessor<string | number>
|
||||
|
||||
/**
|
||||
* Optional item key whose geometry can temporarily hold auto-follow when the
|
||||
* rendered item grows taller than the viewport and reaches the top edge.
|
||||
*/
|
||||
autoPinHoldTargetKey?: Accessor<string | null>
|
||||
|
||||
/**
|
||||
* Optional resolver for the specific element inside an item wrapper that
|
||||
* should be measured for hold-target geometry.
|
||||
*/
|
||||
resolveAutoPinHoldElement?: (itemWrapper: HTMLDivElement, key: string) => HTMLElement | null | undefined
|
||||
|
||||
/**
|
||||
* Top-edge threshold for the hold target in pixels.
|
||||
*/
|
||||
autoPinHoldTopThresholdPx?: number
|
||||
|
||||
/**
|
||||
* Temporarily suppress automatic bottom pinning while keeping follow mode enabled.
|
||||
*/
|
||||
suspendAutoPinToBottom?: Accessor<boolean>
|
||||
|
||||
/**
|
||||
* Optional hooks to render content inside the scroll container.
|
||||
* Useful for empty/loading states that should scroll with the list.
|
||||
@@ -130,13 +154,19 @@ export default function VirtualFollowList<T>(props: VirtualFollowListProps<T>) {
|
||||
const scrollToBottomOnActivate = () => (props.scrollToBottomOnActivate ? props.scrollToBottomOnActivate() : true)
|
||||
const initialScrollToBottom = () => (props.initialScrollToBottom ? props.initialScrollToBottom() : true)
|
||||
const initialAutoScroll = () => (props.initialAutoScroll ? props.initialAutoScroll() : true)
|
||||
const externalSuspendAutoPinToBottom = () => (props.suspendAutoPinToBottom ? props.suspendAutoPinToBottom() : false)
|
||||
const holdTargetKey = () => (props.autoPinHoldTargetKey ? props.autoPinHoldTargetKey() : null)
|
||||
const holdTargetTopThresholdPx = () => props.autoPinHoldTopThresholdPx ?? DEFAULT_HOLD_TARGET_TOP_THRESHOLD_PX
|
||||
|
||||
const [autoScroll, setAutoScroll] = createSignal(Boolean(initialAutoScroll()))
|
||||
const [showScrollTopButton, setShowScrollTopButton] = createSignal(false)
|
||||
const [showScrollBottomButton, setShowScrollBottomButton] = createSignal(false)
|
||||
const [activeKey, setActiveKey] = createSignal<string | null>(null)
|
||||
const [heldItemCount, setHeldItemCount] = createSignal<number | null>(null)
|
||||
const effectiveSuspendAutoPinToBottom = () => externalSuspendAutoPinToBottom() || heldItemCount() !== null
|
||||
|
||||
const scrollButtonsCount = createMemo(() => (showScrollTopButton() ? 1 : 0) + (showScrollBottomButton() ? 1 : 0))
|
||||
const itemElements = new Map<string, HTMLDivElement>()
|
||||
|
||||
let userScrollIntentUntil = 0
|
||||
let lastUserScrollIntentDirection: "up" | "down" | null = null
|
||||
@@ -144,6 +174,8 @@ export default function VirtualFollowList<T>(props: VirtualFollowListProps<T>) {
|
||||
let lastResetKey: string | number | undefined
|
||||
let suppressAutoScrollOnce = false
|
||||
let pendingInitialScroll = true
|
||||
let lastObservedScrollOffset = 0
|
||||
let lastObservedPinnedAtBottom = false
|
||||
|
||||
const state: VirtualFollowListState = {
|
||||
autoScroll,
|
||||
@@ -209,23 +241,42 @@ export default function VirtualFollowList<T>(props: VirtualFollowListProps<T>) {
|
||||
if (!handle || !element) return
|
||||
|
||||
const offset = handle.scrollOffset
|
||||
const scrolledUp = offset < lastObservedScrollOffset - 1
|
||||
const wasPinnedAtBottom = lastObservedPinnedAtBottom
|
||||
const scrollHeight = handle.scrollSize
|
||||
const clientHeight = element.clientHeight
|
||||
const atBottom = scrollHeight - (offset + clientHeight) <= (props.scrollSentinelMarginPx ?? DEFAULT_SCROLL_SENTINEL_MARGIN_PX)
|
||||
const atTop = offset <= (props.scrollSentinelMarginPx ?? DEFAULT_SCROLL_SENTINEL_MARGIN_PX)
|
||||
lastObservedScrollOffset = offset
|
||||
|
||||
const hasItems = props.items().length > 0
|
||||
setShowScrollBottomButton(hasItems && !atBottom)
|
||||
setShowScrollTopButton(hasItems && !atTop)
|
||||
|
||||
// Keyboard/PageUp scrolls can move the viewport without ever hitting our
|
||||
// local key intent listeners (for example after dragging the native
|
||||
// scrollbar). If follow mode stays enabled, the next render notification
|
||||
// snaps the list straight back to bottom. A real upward viewport move away
|
||||
// from bottom should always break follow unless a hold target is active.
|
||||
if (wasPinnedAtBottom && scrolledUp && autoScroll() && !atBottom && heldItemCount() === null) {
|
||||
setAutoScroll(false)
|
||||
lastObservedPinnedAtBottom = false
|
||||
return
|
||||
}
|
||||
|
||||
// Sync autoScroll state based on scroll position if it was a user scroll
|
||||
if (hasUserScrollIntent()) {
|
||||
if (atBottom && heldItemCount() !== null) {
|
||||
setHeldItemCount(null)
|
||||
}
|
||||
if (atBottom && !autoScroll()) {
|
||||
setAutoScroll(true)
|
||||
} else if (!atBottom && autoScroll()) {
|
||||
setAutoScroll(false)
|
||||
}
|
||||
}
|
||||
|
||||
lastObservedPinnedAtBottom = autoScroll() && atBottom
|
||||
}
|
||||
|
||||
function scrollToBottom(immediate = true, options?: { suppressAutoAnchor?: boolean }) {
|
||||
@@ -253,6 +304,7 @@ export default function VirtualFollowList<T>(props: VirtualFollowListProps<T>) {
|
||||
}
|
||||
}
|
||||
updateScrollButtons()
|
||||
updateAutoPinHold()
|
||||
props.onScroll?.()
|
||||
|
||||
// Find active key (roughly the first visible item)
|
||||
@@ -270,6 +322,68 @@ export default function VirtualFollowList<T>(props: VirtualFollowListProps<T>) {
|
||||
}
|
||||
}
|
||||
|
||||
function registerItemElement(key: string, element: HTMLDivElement | null | undefined) {
|
||||
if (!element) {
|
||||
itemElements.delete(key)
|
||||
return
|
||||
}
|
||||
itemElements.set(key, element)
|
||||
}
|
||||
|
||||
function getAnchorIdForKey(key: string) {
|
||||
return props.getAnchorId ? props.getAnchorId(key) : key
|
||||
}
|
||||
|
||||
function updateAutoPinHold() {
|
||||
const element = scrollElement()
|
||||
const itemCount = props.items().length
|
||||
const heldCount = heldItemCount()
|
||||
if (!element) return
|
||||
|
||||
if (heldCount !== null) {
|
||||
if (itemCount > heldCount) {
|
||||
setHeldItemCount(null)
|
||||
if (autoScroll()) {
|
||||
requestAnimationFrame(() => {
|
||||
if (!autoScroll()) return
|
||||
scrollToBottom(false)
|
||||
})
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (itemCount < heldCount) {
|
||||
setHeldItemCount(null)
|
||||
return
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if (!autoScroll()) return
|
||||
if (externalSuspendAutoPinToBottom()) return
|
||||
|
||||
const targetKey = holdTargetKey()
|
||||
if (!targetKey) return
|
||||
|
||||
const itemWrapper = itemElements.get(targetKey)
|
||||
if (!itemWrapper) return
|
||||
const target = props.resolveAutoPinHoldElement?.(itemWrapper, targetKey) ?? itemWrapper
|
||||
|
||||
const containerRect = element.getBoundingClientRect()
|
||||
const targetRect = target.getBoundingClientRect()
|
||||
const relativeTop = targetRect.top - containerRect.top
|
||||
const exceedsViewport = targetRect.height > element.clientHeight
|
||||
|
||||
if (
|
||||
exceedsViewport &&
|
||||
relativeTop <= holdTargetTopThresholdPx() &&
|
||||
relativeTop >= holdTargetTopThresholdPx() - DEFAULT_HOLD_TARGET_TOP_OVERSHOOT_PX
|
||||
) {
|
||||
setHeldItemCount(itemCount)
|
||||
}
|
||||
}
|
||||
|
||||
const api: VirtualFollowListApi = {
|
||||
scrollToTop: (opts) => scrollToTop(opts?.immediate ?? true),
|
||||
scrollToBottom: (opts) => scrollToBottom(opts?.immediate ?? true, { suppressAutoAnchor: opts?.suppressAutoAnchor }),
|
||||
@@ -281,7 +395,9 @@ export default function VirtualFollowList<T>(props: VirtualFollowListProps<T>) {
|
||||
virtuaHandle()?.scrollToIndex(index, { align: opts?.block ?? "start", smooth: opts?.behavior === "smooth" })
|
||||
},
|
||||
notifyContentRendered: () => {
|
||||
if (autoScroll()) {
|
||||
updateAutoPinHold()
|
||||
if (heldItemCount() !== null) return
|
||||
if (autoScroll() && !effectiveSuspendAutoPinToBottom()) {
|
||||
scrollToBottom(true)
|
||||
}
|
||||
},
|
||||
@@ -294,9 +410,17 @@ export default function VirtualFollowList<T>(props: VirtualFollowListProps<T>) {
|
||||
createEffect(() => props.registerApi?.(api))
|
||||
createEffect(() => props.registerState?.(state))
|
||||
|
||||
createEffect(on(() => props.resetKey?.(), () => {
|
||||
itemElements.clear()
|
||||
setHeldItemCount(null)
|
||||
lastObservedScrollOffset = 0
|
||||
lastObservedPinnedAtBottom = false
|
||||
}))
|
||||
|
||||
// Handle autoScroll (Follow) on items change
|
||||
createEffect(on(() => props.items().length, (len, prevLen) => {
|
||||
if (len > (prevLen ?? 0) && autoScroll() && !suppressAutoScrollOnce) {
|
||||
updateAutoPinHold()
|
||||
if (len > (prevLen ?? 0) && autoScroll() && !effectiveSuspendAutoPinToBottom() && !suppressAutoScrollOnce) {
|
||||
requestAnimationFrame(() => scrollToBottom(true))
|
||||
}
|
||||
suppressAutoScrollOnce = false
|
||||
@@ -304,11 +428,16 @@ export default function VirtualFollowList<T>(props: VirtualFollowListProps<T>) {
|
||||
|
||||
// Handle followToken change
|
||||
createEffect(on(() => props.followToken?.(), () => {
|
||||
if (autoScroll()) {
|
||||
updateAutoPinHold()
|
||||
if (autoScroll() && !effectiveSuspendAutoPinToBottom()) {
|
||||
scrollToBottom(true)
|
||||
}
|
||||
}, { defer: true }))
|
||||
|
||||
createEffect(on(() => holdTargetKey(), () => {
|
||||
updateAutoPinHold()
|
||||
}, { defer: true }))
|
||||
|
||||
// Reset state on resetKey change
|
||||
createEffect(on(() => props.resetKey?.(), (nextKey) => {
|
||||
if (nextKey === lastResetKey) return
|
||||
@@ -331,6 +460,13 @@ export default function VirtualFollowList<T>(props: VirtualFollowListProps<T>) {
|
||||
}
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
if (typeof window === "undefined") return
|
||||
const handleResize = () => updateAutoPinHold()
|
||||
window.addEventListener("resize", handleResize)
|
||||
onCleanup(() => window.removeEventListener("resize", handleResize))
|
||||
})
|
||||
|
||||
return (
|
||||
<div class="virtual-follow-list-shell" ref={shellElement => {
|
||||
setShellElement(shellElement)
|
||||
@@ -356,7 +492,15 @@ export default function VirtualFollowList<T>(props: VirtualFollowListProps<T>) {
|
||||
bufferSize={props.overscanPx ?? 400}
|
||||
onScroll={handleScroll}
|
||||
>
|
||||
{(item, index) => props.renderItem(item, index())}
|
||||
{(item, index) => {
|
||||
const key = props.getKey(item, index())
|
||||
const anchorId = getAnchorIdForKey(key)
|
||||
return (
|
||||
<div id={anchorId} data-virtual-follow-key={key} ref={(element) => registerItemElement(key, element)}>
|
||||
{props.renderItem(item, index())}
|
||||
</div>
|
||||
)
|
||||
}}
|
||||
</Virtualizer>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -12,9 +12,16 @@ import type {
|
||||
SpeechTranscriptionResponse,
|
||||
SideCar,
|
||||
ServerMeta,
|
||||
RemoteProxySessionCreateRequest,
|
||||
RemoteProxySessionCreateResponse,
|
||||
RemoteServerProbeRequest,
|
||||
RemoteServerProbeResponse,
|
||||
VoiceModeStateResponse,
|
||||
WorktreeGitCommitRequest,
|
||||
WorktreeGitCommitResponse,
|
||||
WorktreeGitDiffRequest,
|
||||
WorktreeGitMutationResponse,
|
||||
WorktreeGitPathsRequest,
|
||||
WorkspaceCreateRequest,
|
||||
WorkspaceDescriptor,
|
||||
WorkspaceFileResponse,
|
||||
@@ -26,6 +33,8 @@ import type {
|
||||
WorktreeListResponse,
|
||||
WorktreeMap,
|
||||
WorktreeCreateRequest,
|
||||
WorktreeGitDiffResponse,
|
||||
WorktreeGitStatusResponse,
|
||||
} from "../../../server/src/api-types"
|
||||
import { getClientIdentity } from "./client-identity"
|
||||
import { getLogger } from "./logger"
|
||||
@@ -98,6 +107,25 @@ function logHttp(message: string, context?: Record<string, unknown>) {
|
||||
httpLogger.info(message)
|
||||
}
|
||||
|
||||
async function readErrorMessage(response: Response): Promise<string> {
|
||||
const text = await response.text()
|
||||
if (!text) return `Request failed with ${response.status}`
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(text) as { error?: unknown; message?: unknown }
|
||||
if (typeof parsed?.error === "string" && parsed.error.trim()) {
|
||||
return parsed.error
|
||||
}
|
||||
if (typeof parsed?.message === "string" && parsed.message.trim()) {
|
||||
return parsed.message
|
||||
}
|
||||
} catch {
|
||||
// Keep the original body for plain-text responses.
|
||||
}
|
||||
|
||||
return text
|
||||
}
|
||||
|
||||
async function request<T>(path: string, init?: RequestInit): Promise<T> {
|
||||
const url = API_BASE ? new URL(path, API_BASE).toString() : path
|
||||
const headers = normalizeHeaders(init?.headers)
|
||||
@@ -112,7 +140,7 @@ async function request<T>(path: string, init?: RequestInit): Promise<T> {
|
||||
try {
|
||||
const response = await fetch(url, { ...init, headers, credentials: init?.credentials ?? "include" })
|
||||
if (!response.ok) {
|
||||
const message = await response.text()
|
||||
const message = await readErrorMessage(response)
|
||||
logHttp(`${method} ${path} -> ${response.status}`, { durationMs: Date.now() - startedAt, error: message })
|
||||
throw new Error(message || `Request failed with ${response.status}`)
|
||||
}
|
||||
@@ -141,7 +169,7 @@ async function requestRaw(path: string, init?: RequestInit): Promise<Response> {
|
||||
|
||||
const response = await fetch(url, { ...init, headers, credentials: init?.credentials ?? "include" })
|
||||
if (!response.ok) {
|
||||
const message = await response.text()
|
||||
const message = await readErrorMessage(response)
|
||||
logHttp(`${method} ${path} -> ${response.status}`, { durationMs: Date.now() - startedAt, error: message })
|
||||
throw new Error(message || `Request failed with ${response.status}`)
|
||||
}
|
||||
@@ -230,6 +258,12 @@ export const serverApi = {
|
||||
body: JSON.stringify(payload),
|
||||
})
|
||||
},
|
||||
createRemoteProxySession(payload: RemoteProxySessionCreateRequest): Promise<RemoteProxySessionCreateResponse> {
|
||||
return request<RemoteProxySessionCreateResponse>("/api/remote-proxy/sessions", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(payload),
|
||||
})
|
||||
},
|
||||
fetchAuthStatus(): Promise<{ authenticated: boolean; username?: string; passwordUserProvided?: boolean }> {
|
||||
return request<{ authenticated: boolean; username?: string; passwordUserProvided?: boolean }>("/api/auth/status")
|
||||
},
|
||||
@@ -282,6 +316,47 @@ export const serverApi = {
|
||||
},
|
||||
)
|
||||
},
|
||||
fetchWorktreeGitStatus(id: string, slug: string): Promise<WorktreeGitStatusResponse> {
|
||||
return request<WorktreeGitStatusResponse>(
|
||||
`/api/workspaces/${encodeURIComponent(id)}/worktrees/${encodeURIComponent(slug)}/git-status`,
|
||||
)
|
||||
},
|
||||
fetchWorktreeGitDiff(id: string, slug: string, requestPayload: WorktreeGitDiffRequest): Promise<WorktreeGitDiffResponse> {
|
||||
const params = new URLSearchParams({ path: requestPayload.path, scope: requestPayload.scope })
|
||||
if (requestPayload.originalPath) {
|
||||
params.set("originalPath", requestPayload.originalPath)
|
||||
}
|
||||
return request<WorktreeGitDiffResponse>(
|
||||
`/api/workspaces/${encodeURIComponent(id)}/worktrees/${encodeURIComponent(slug)}/git-diff?${params.toString()}`,
|
||||
)
|
||||
},
|
||||
stageWorktreeGitPaths(id: string, slug: string, payload: WorktreeGitPathsRequest): Promise<WorktreeGitMutationResponse> {
|
||||
return request<WorktreeGitMutationResponse>(
|
||||
`/api/workspaces/${encodeURIComponent(id)}/worktrees/${encodeURIComponent(slug)}/git-stage`,
|
||||
{
|
||||
method: "POST",
|
||||
body: JSON.stringify(payload),
|
||||
},
|
||||
)
|
||||
},
|
||||
unstageWorktreeGitPaths(id: string, slug: string, payload: WorktreeGitPathsRequest): Promise<WorktreeGitMutationResponse> {
|
||||
return request<WorktreeGitMutationResponse>(
|
||||
`/api/workspaces/${encodeURIComponent(id)}/worktrees/${encodeURIComponent(slug)}/git-unstage`,
|
||||
{
|
||||
method: "POST",
|
||||
body: JSON.stringify(payload),
|
||||
},
|
||||
)
|
||||
},
|
||||
commitWorktreeGitChanges(id: string, slug: string, payload: WorktreeGitCommitRequest): Promise<WorktreeGitCommitResponse> {
|
||||
return request<WorktreeGitCommitResponse>(
|
||||
`/api/workspaces/${encodeURIComponent(id)}/worktrees/${encodeURIComponent(slug)}/git-commit`,
|
||||
{
|
||||
method: "POST",
|
||||
body: JSON.stringify(payload),
|
||||
},
|
||||
)
|
||||
},
|
||||
|
||||
fetchConfigOwner<T extends Record<string, any> = Record<string, any>>(owner: string): Promise<T> {
|
||||
return request<T>(`/api/storage/config/${encodeURIComponent(owner)}`)
|
||||
|
||||
@@ -16,7 +16,7 @@ export interface FollowScrollHelpers {
|
||||
registerContainer: (element: HTMLDivElement | null | undefined, options?: { disableTracking?: boolean }) => void
|
||||
handleScroll: (event: Event & { currentTarget: HTMLDivElement }) => void
|
||||
renderSentinel: (options?: { disableTracking?: boolean }) => JSXElement | null
|
||||
restoreAfterRender: (options?: { forceBottom?: boolean }) => void
|
||||
restoreAfterRender: () => void
|
||||
autoScroll: Accessor<boolean>
|
||||
}
|
||||
|
||||
@@ -183,7 +183,7 @@ export function createFollowScroll(options: FollowScrollOptions): FollowScrollHe
|
||||
return <div ref={setBottomSentinel} aria-hidden="true" class={options.sentinelClassName} style={{ height: "1px" }} />
|
||||
}
|
||||
|
||||
const restoreAfterRender = (config?: { forceBottom?: boolean }) => {
|
||||
const restoreAfterRender = () => {
|
||||
const container = scrollContainerRef
|
||||
if (container && hasUserScrollIntent() && !isAtBottom(container)) {
|
||||
if (autoScroll()) {
|
||||
@@ -195,7 +195,10 @@ export function createFollowScroll(options: FollowScrollOptions): FollowScrollHe
|
||||
return
|
||||
}
|
||||
|
||||
const shouldFollow = config?.forceBottom ?? autoScroll()
|
||||
// Never let a render-time caller force follow mode back on after the user
|
||||
// has already escaped it. Staying pinned should depend on the current
|
||||
// follow state, not on a caller opting into forceBottom.
|
||||
const shouldFollow = autoScroll()
|
||||
requestAnimationFrame(() => {
|
||||
restoreScrollPosition(shouldFollow)
|
||||
if (shouldFollow) {
|
||||
|
||||
@@ -131,6 +131,17 @@ export const instanceMessages = {
|
||||
"instanceShell.gitChanges.loading": "Loading git changes...",
|
||||
"instanceShell.gitChanges.empty": "No git changes yet.",
|
||||
"instanceShell.gitChanges.deleted": "Deleted",
|
||||
"instanceShell.gitChanges.binaryViewer": "Binary file cannot be displayed",
|
||||
"instanceShell.gitChanges.sections.staged": "Staged Changes",
|
||||
"instanceShell.gitChanges.sections.unstaged": "Changes",
|
||||
"instanceShell.gitChanges.actions.insertContext": "Add to prompt",
|
||||
"instanceShell.gitChanges.actions.stage": "Stage file",
|
||||
"instanceShell.gitChanges.actions.unstage": "Unstage file",
|
||||
"instanceShell.gitChanges.commit.placeholder": "Enter commit message",
|
||||
"instanceShell.gitChanges.commit.submit": "Commit",
|
||||
"instanceShell.gitChanges.commit.submitting": "Committing...",
|
||||
"instanceShell.gitChanges.commit.success": "Commit created successfully",
|
||||
"instanceShell.gitChanges.commit.error": "Failed to create commit",
|
||||
|
||||
"instanceShell.filesShell.fileListTitle": "File list",
|
||||
"instanceShell.filesShell.mobileSelectorLabel": "Select file",
|
||||
|
||||
@@ -18,6 +18,8 @@ export const messagingMessages = {
|
||||
"messageSection.loading.messages": "Loading messages...",
|
||||
"messageSection.scroll.toFirstAriaLabel": "Scroll to first message",
|
||||
"messageSection.scroll.toLatestAriaLabel": "Scroll to latest message",
|
||||
"messageSection.scroll.enableHoldAriaLabel": "Enable hold for long assistant replies",
|
||||
"messageSection.scroll.disableHoldAriaLabel": "Disable hold for long assistant replies",
|
||||
"messageSection.quote.addAsQuote": "Add as quote",
|
||||
"messageSection.quote.addAsCode": "Add as code",
|
||||
"messageSection.quote.copy": "Copy",
|
||||
|
||||
@@ -130,6 +130,17 @@ export const instanceMessages = {
|
||||
"instanceShell.gitChanges.loading": "Cargando cambios de Git...",
|
||||
"instanceShell.gitChanges.empty": "Aún no hay cambios de Git.",
|
||||
"instanceShell.gitChanges.deleted": "Eliminado",
|
||||
"instanceShell.gitChanges.binaryViewer": "No se puede mostrar un archivo binario",
|
||||
"instanceShell.gitChanges.sections.staged": "Cambios preparados",
|
||||
"instanceShell.gitChanges.sections.unstaged": "Cambios",
|
||||
"instanceShell.gitChanges.actions.insertContext": "Agregar al prompt",
|
||||
"instanceShell.gitChanges.actions.stage": "Preparar archivo",
|
||||
"instanceShell.gitChanges.actions.unstage": "Quitar del área preparada",
|
||||
"instanceShell.gitChanges.commit.placeholder": "Escribe el mensaje del commit",
|
||||
"instanceShell.gitChanges.commit.submit": "Commit",
|
||||
"instanceShell.gitChanges.commit.submitting": "Confirmando...",
|
||||
"instanceShell.gitChanges.commit.success": "Commit creado correctamente",
|
||||
"instanceShell.gitChanges.commit.error": "No se pudo crear el commit",
|
||||
|
||||
"instanceShell.filesShell.fileListTitle": "Lista de archivos",
|
||||
"instanceShell.filesShell.mobileSelectorLabel": "Seleccionar archivo",
|
||||
|
||||
@@ -18,6 +18,8 @@ export const messagingMessages = {
|
||||
"messageSection.loading.messages": "Cargando mensajes...",
|
||||
"messageSection.scroll.toFirstAriaLabel": "Desplazarse al primer mensaje",
|
||||
"messageSection.scroll.toLatestAriaLabel": "Desplazarse al último mensaje",
|
||||
"messageSection.scroll.enableHoldAriaLabel": "Activar pausa para respuestas largas del asistente",
|
||||
"messageSection.scroll.disableHoldAriaLabel": "Desactivar pausa para respuestas largas del asistente",
|
||||
"messageSection.quote.addAsQuote": "Añadir como cita",
|
||||
"messageSection.quote.addAsCode": "Añadir como código",
|
||||
"messageSection.quote.copy": "Copiar",
|
||||
|
||||
@@ -130,6 +130,17 @@ export const instanceMessages = {
|
||||
"instanceShell.gitChanges.loading": "Chargement des changements Git...",
|
||||
"instanceShell.gitChanges.empty": "Aucun changement Git pour l'instant.",
|
||||
"instanceShell.gitChanges.deleted": "Supprimé",
|
||||
"instanceShell.gitChanges.binaryViewer": "Impossible d'afficher un fichier binaire",
|
||||
"instanceShell.gitChanges.sections.staged": "Changements indexés",
|
||||
"instanceShell.gitChanges.sections.unstaged": "Changements",
|
||||
"instanceShell.gitChanges.actions.insertContext": "Ajouter au prompt",
|
||||
"instanceShell.gitChanges.actions.stage": "Indexer le fichier",
|
||||
"instanceShell.gitChanges.actions.unstage": "Retirer de l'index",
|
||||
"instanceShell.gitChanges.commit.placeholder": "Saisissez le message du commit",
|
||||
"instanceShell.gitChanges.commit.submit": "Valider",
|
||||
"instanceShell.gitChanges.commit.submitting": "Validation...",
|
||||
"instanceShell.gitChanges.commit.success": "Commit créé avec succès",
|
||||
"instanceShell.gitChanges.commit.error": "Impossible de créer le commit",
|
||||
|
||||
"instanceShell.filesShell.fileListTitle": "Liste des fichiers",
|
||||
"instanceShell.filesShell.mobileSelectorLabel": "Sélectionner un fichier",
|
||||
|
||||
@@ -18,6 +18,8 @@ export const messagingMessages = {
|
||||
"messageSection.loading.messages": "Chargement des messages...",
|
||||
"messageSection.scroll.toFirstAriaLabel": "Aller au premier message",
|
||||
"messageSection.scroll.toLatestAriaLabel": "Aller au dernier message",
|
||||
"messageSection.scroll.enableHoldAriaLabel": "Activer le maintien pour les longues réponses de l'assistant",
|
||||
"messageSection.scroll.disableHoldAriaLabel": "Désactiver le maintien pour les longues réponses de l'assistant",
|
||||
"messageSection.quote.addAsQuote": "Ajouter en citation",
|
||||
"messageSection.quote.addAsCode": "Ajouter en code",
|
||||
"messageSection.quote.copy": "Copier",
|
||||
|
||||
@@ -138,6 +138,17 @@ export const instanceMessages = {
|
||||
"instanceShell.gitChanges.noSessionSelected": "בחר סשן לצפייה בשינויי Git.",
|
||||
"instanceShell.gitChanges.loading": "טוען שינויי Git…",
|
||||
"instanceShell.gitChanges.empty": "אין שינויי Git עדיין.",
|
||||
"instanceShell.gitChanges.binaryViewer": "לא ניתן להציג קובץ בינארי",
|
||||
"instanceShell.gitChanges.sections.staged": "שינויים שנשמרו ל-staging",
|
||||
"instanceShell.gitChanges.sections.unstaged": "שינויים",
|
||||
"instanceShell.gitChanges.actions.insertContext": "הוסף לפרומפט",
|
||||
"instanceShell.gitChanges.actions.stage": "העבר ל-staging",
|
||||
"instanceShell.gitChanges.actions.unstage": "הוצא מ-staging",
|
||||
"instanceShell.gitChanges.commit.placeholder": "הזן הודעת commit",
|
||||
"instanceShell.gitChanges.commit.submit": "Commit",
|
||||
"instanceShell.gitChanges.commit.submitting": "מבצע commit...",
|
||||
"instanceShell.gitChanges.commit.success": "ה-commit נוצר בהצלחה",
|
||||
"instanceShell.gitChanges.commit.error": "יצירת ה-commit נכשלה",
|
||||
"instanceShell.diff.hideUnchanged": "הסתר אזורים ללא שינוי",
|
||||
"instanceShell.diff.showFull": "הצג קובץ מלא",
|
||||
"instanceShell.diff.switchToSplit": "עבור לתצוגה מפוצלת",
|
||||
|
||||
@@ -18,6 +18,8 @@ export const messagingMessages = {
|
||||
"messageSection.loading.messages": "טוען הודעות...",
|
||||
"messageSection.scroll.toFirstAriaLabel": "גלול להודעה הראשונה",
|
||||
"messageSection.scroll.toLatestAriaLabel": "גלול להודעה האחרונה",
|
||||
"messageSection.scroll.enableHoldAriaLabel": "הפעל עצירה לתגובות עוזר ארוכות",
|
||||
"messageSection.scroll.disableHoldAriaLabel": "כבה עצירה לתגובות עוזר ארוכות",
|
||||
"messageSection.quote.addAsQuote": "הוסף כציטוט",
|
||||
"messageSection.quote.addAsCode": "הוסף כקוד",
|
||||
"messageSection.quote.copy": "העתק",
|
||||
|
||||
@@ -130,6 +130,17 @@ export const instanceMessages = {
|
||||
"instanceShell.gitChanges.loading": "Git の変更を読み込み中...",
|
||||
"instanceShell.gitChanges.empty": "Git の変更はまだありません。",
|
||||
"instanceShell.gitChanges.deleted": "削除済み",
|
||||
"instanceShell.gitChanges.binaryViewer": "バイナリファイルは表示できません",
|
||||
"instanceShell.gitChanges.sections.staged": "ステージ済みの変更",
|
||||
"instanceShell.gitChanges.sections.unstaged": "変更",
|
||||
"instanceShell.gitChanges.actions.insertContext": "プロンプトに追加",
|
||||
"instanceShell.gitChanges.actions.stage": "ファイルをステージ",
|
||||
"instanceShell.gitChanges.actions.unstage": "ステージ解除",
|
||||
"instanceShell.gitChanges.commit.placeholder": "コミットメッセージを入力",
|
||||
"instanceShell.gitChanges.commit.submit": "コミット",
|
||||
"instanceShell.gitChanges.commit.submitting": "コミット中...",
|
||||
"instanceShell.gitChanges.commit.success": "コミットを作成しました",
|
||||
"instanceShell.gitChanges.commit.error": "コミットを作成できませんでした",
|
||||
|
||||
"instanceShell.filesShell.fileListTitle": "ファイル一覧",
|
||||
"instanceShell.filesShell.mobileSelectorLabel": "ファイルを選択",
|
||||
|
||||
@@ -18,6 +18,8 @@ export const messagingMessages = {
|
||||
"messageSection.loading.messages": "メッセージを読み込み中...",
|
||||
"messageSection.scroll.toFirstAriaLabel": "最初のメッセージへスクロール",
|
||||
"messageSection.scroll.toLatestAriaLabel": "最新のメッセージへスクロール",
|
||||
"messageSection.scroll.enableHoldAriaLabel": "長いアシスタント返信の保持を有効にする",
|
||||
"messageSection.scroll.disableHoldAriaLabel": "長いアシスタント返信の保持を無効にする",
|
||||
"messageSection.quote.addAsQuote": "引用として追加",
|
||||
"messageSection.quote.addAsCode": "コードとして追加",
|
||||
"messageSection.quote.copy": "コピー",
|
||||
|
||||
@@ -130,6 +130,17 @@ export const instanceMessages = {
|
||||
"instanceShell.gitChanges.loading": "Загрузка изменений Git...",
|
||||
"instanceShell.gitChanges.empty": "Изменений Git пока нет.",
|
||||
"instanceShell.gitChanges.deleted": "Удалено",
|
||||
"instanceShell.gitChanges.binaryViewer": "Невозможно показать бинарный файл",
|
||||
"instanceShell.gitChanges.sections.staged": "Подготовленные изменения",
|
||||
"instanceShell.gitChanges.sections.unstaged": "Изменения",
|
||||
"instanceShell.gitChanges.actions.insertContext": "Добавить в промпт",
|
||||
"instanceShell.gitChanges.actions.stage": "Подготовить файл",
|
||||
"instanceShell.gitChanges.actions.unstage": "Убрать из staging",
|
||||
"instanceShell.gitChanges.commit.placeholder": "Введите сообщение коммита",
|
||||
"instanceShell.gitChanges.commit.submit": "Commit",
|
||||
"instanceShell.gitChanges.commit.submitting": "Создание commit...",
|
||||
"instanceShell.gitChanges.commit.success": "Commit успешно создан",
|
||||
"instanceShell.gitChanges.commit.error": "Не удалось создать commit",
|
||||
|
||||
"instanceShell.filesShell.fileListTitle": "Список файлов",
|
||||
"instanceShell.filesShell.mobileSelectorLabel": "Выбрать файл",
|
||||
|
||||
@@ -18,6 +18,8 @@ export const messagingMessages = {
|
||||
"messageSection.loading.messages": "Загрузка сообщений…",
|
||||
"messageSection.scroll.toFirstAriaLabel": "Прокрутить к первому сообщению",
|
||||
"messageSection.scroll.toLatestAriaLabel": "Прокрутить к последнему сообщению",
|
||||
"messageSection.scroll.enableHoldAriaLabel": "Включить удержание для длинных ответов ассистента",
|
||||
"messageSection.scroll.disableHoldAriaLabel": "Выключить удержание для длинных ответов ассистента",
|
||||
"messageSection.quote.addAsQuote": "Добавить как цитату",
|
||||
"messageSection.quote.addAsCode": "Добавить как код",
|
||||
"messageSection.quote.copy": "Копировать",
|
||||
|
||||
@@ -130,6 +130,17 @@ export const instanceMessages = {
|
||||
"instanceShell.gitChanges.loading": "正在加载 Git 更改...",
|
||||
"instanceShell.gitChanges.empty": "暂无 Git 更改。",
|
||||
"instanceShell.gitChanges.deleted": "已删除",
|
||||
"instanceShell.gitChanges.binaryViewer": "无法显示二进制文件",
|
||||
"instanceShell.gitChanges.sections.staged": "已暂存的更改",
|
||||
"instanceShell.gitChanges.sections.unstaged": "更改",
|
||||
"instanceShell.gitChanges.actions.insertContext": "添加到提示词",
|
||||
"instanceShell.gitChanges.actions.stage": "暂存文件",
|
||||
"instanceShell.gitChanges.actions.unstage": "取消暂存",
|
||||
"instanceShell.gitChanges.commit.placeholder": "输入提交信息",
|
||||
"instanceShell.gitChanges.commit.submit": "提交",
|
||||
"instanceShell.gitChanges.commit.submitting": "正在提交...",
|
||||
"instanceShell.gitChanges.commit.success": "提交已成功创建",
|
||||
"instanceShell.gitChanges.commit.error": "无法创建提交",
|
||||
|
||||
"instanceShell.filesShell.fileListTitle": "文件列表",
|
||||
"instanceShell.filesShell.mobileSelectorLabel": "选择文件",
|
||||
|
||||
@@ -18,6 +18,8 @@ export const messagingMessages = {
|
||||
"messageSection.loading.messages": "正在加载消息...",
|
||||
"messageSection.scroll.toFirstAriaLabel": "滚动到第一条消息",
|
||||
"messageSection.scroll.toLatestAriaLabel": "滚动到最新消息",
|
||||
"messageSection.scroll.enableHoldAriaLabel": "启用长助手回复保持",
|
||||
"messageSection.scroll.disableHoldAriaLabel": "禁用长助手回复保持",
|
||||
"messageSection.quote.addAsQuote": "作为引用添加",
|
||||
"messageSection.quote.addAsCode": "作为代码添加",
|
||||
"messageSection.quote.copy": "复制",
|
||||
|
||||
@@ -6,14 +6,19 @@ export interface RemoteWindowOpenPayload {
|
||||
id: string
|
||||
name: string
|
||||
baseUrl: string
|
||||
entryUrl?: string
|
||||
skipTlsVerify: boolean
|
||||
}
|
||||
|
||||
export async function openRemoteServerWindow(profile: Pick<RemoteServerProfile, "id" | "name" | "baseUrl" | "skipTlsVerify">): Promise<void> {
|
||||
export async function openRemoteServerWindow(
|
||||
profile: Pick<RemoteServerProfile, "id" | "name" | "baseUrl" | "skipTlsVerify">,
|
||||
entryUrl?: string,
|
||||
): Promise<void> {
|
||||
const payload: RemoteWindowOpenPayload = {
|
||||
id: profile.id,
|
||||
name: profile.name,
|
||||
baseUrl: profile.baseUrl,
|
||||
entryUrl,
|
||||
skipTlsVerify: profile.skipTlsVerify,
|
||||
}
|
||||
|
||||
|
||||
@@ -55,6 +55,7 @@ export interface UiSettings {
|
||||
showKeyboardShortcutHints: boolean
|
||||
thinkingBlocksExpansion: ExpansionPreference
|
||||
showTimelineTools: boolean
|
||||
holdLongAssistantReplies: boolean
|
||||
promptSubmitOnEnter: boolean
|
||||
showPromptVoiceInput: boolean
|
||||
locale?: string
|
||||
@@ -133,6 +134,7 @@ const defaultUiSettings: UiSettings = {
|
||||
showKeyboardShortcutHints: true,
|
||||
thinkingBlocksExpansion: "expanded",
|
||||
showTimelineTools: true,
|
||||
holdLongAssistantReplies: true,
|
||||
promptSubmitOnEnter: false,
|
||||
showPromptVoiceInput: true,
|
||||
diffViewMode: "split",
|
||||
@@ -166,6 +168,7 @@ function normalizeUiSettings(input?: Partial<UiSettings> | null): UiSettings {
|
||||
sanitized.showKeyboardShortcutHints ?? defaultUiSettings.showKeyboardShortcutHints,
|
||||
thinkingBlocksExpansion: sanitized.thinkingBlocksExpansion ?? defaultUiSettings.thinkingBlocksExpansion,
|
||||
showTimelineTools: sanitized.showTimelineTools ?? defaultUiSettings.showTimelineTools,
|
||||
holdLongAssistantReplies: sanitized.holdLongAssistantReplies ?? defaultUiSettings.holdLongAssistantReplies,
|
||||
promptSubmitOnEnter: sanitized.promptSubmitOnEnter ?? defaultUiSettings.promptSubmitOnEnter,
|
||||
showPromptVoiceInput: sanitized.showPromptVoiceInput ?? defaultUiSettings.showPromptVoiceInput,
|
||||
locale: sanitized.locale ?? defaultUiSettings.locale,
|
||||
|
||||
@@ -242,6 +242,10 @@
|
||||
color: var(--accent-primary);
|
||||
}
|
||||
|
||||
.message-scroll-button[data-active="false"] .message-scroll-icon--toggle {
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.message-quote-popover {
|
||||
position: absolute;
|
||||
z-index: 5;
|
||||
|
||||
@@ -66,10 +66,11 @@
|
||||
}
|
||||
|
||||
.message-timeline {
|
||||
--message-timeline-segment-gap: 0.35rem;
|
||||
flex: 1 1 auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.35rem;
|
||||
gap: 0;
|
||||
padding: 0.25rem;
|
||||
overflow-y: auto;
|
||||
overflow-x: visible;
|
||||
@@ -114,6 +115,17 @@
|
||||
-webkit-touch-callout: none;
|
||||
}
|
||||
|
||||
.message-timeline-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.message-timeline-item-spacer {
|
||||
flex: none;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.message-timeline-segment[data-delete-hover="true"]::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
@@ -319,18 +331,7 @@
|
||||
border-inline-start: 3px solid color-mix(in oklab, var(--accent-primary) 35%, transparent);
|
||||
}
|
||||
|
||||
/* Extra spacing before the first tool in a group to separate from the
|
||||
preceding user/assistant badge. */
|
||||
.message-timeline-group-start {
|
||||
margin-top: 0.35rem;
|
||||
}
|
||||
|
||||
/* Subtle extra spacing after the group parent (assistant) to separate
|
||||
from the next user badge below. Uses adjacent sibling targeting. */
|
||||
.message-timeline-group-parent + .message-timeline-user,
|
||||
.message-timeline-group-parent + .message-timeline-compaction {
|
||||
margin-top: 0.35rem;
|
||||
}
|
||||
/* Spacing is rendered by the measured item wrapper so virtua can account for it. */
|
||||
|
||||
.message-timeline-container {
|
||||
position: relative;
|
||||
|
||||
@@ -220,7 +220,7 @@
|
||||
}
|
||||
|
||||
.file-list-item {
|
||||
@apply px-3 py-2.5 border-b cursor-pointer transition-all duration-150;
|
||||
@apply px-2 py-1 border-b cursor-pointer transition-all duration-150;
|
||||
border-color: var(--border-base);
|
||||
background-color: transparent;
|
||||
}
|
||||
@@ -234,14 +234,280 @@
|
||||
}
|
||||
|
||||
.file-list-item-active {
|
||||
background-color: var(--surface-base);
|
||||
background-color: color-mix(in srgb, var(--surface-base) 88%, white);
|
||||
box-shadow: inset 0 0 0 1px var(--accent-primary);
|
||||
}
|
||||
|
||||
.git-change-list-item-bulk-selected {
|
||||
background-color: color-mix(in srgb, var(--accent-primary) 12%, var(--surface-base));
|
||||
box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--accent-primary) 45%, transparent);
|
||||
}
|
||||
|
||||
.git-change-list-item-bulk-selected:hover {
|
||||
background-color: color-mix(in srgb, var(--accent-primary) 12%, var(--surface-base));
|
||||
}
|
||||
|
||||
.git-change-list-item-bulk-selected.file-list-item-active {
|
||||
background-color: color-mix(in srgb, var(--accent-primary) 18%, var(--surface-base));
|
||||
box-shadow:
|
||||
inset 0 0 0 1px var(--accent-primary),
|
||||
inset 0 0 0 2px color-mix(in srgb, var(--accent-primary) 20%, transparent);
|
||||
}
|
||||
|
||||
.file-list-item-content {
|
||||
@apply flex items-center justify-between gap-3;
|
||||
}
|
||||
|
||||
.git-change-list-item .file-list-item-content {
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.git-change-sections {
|
||||
@apply flex flex-col;
|
||||
}
|
||||
|
||||
.git-change-commit-box {
|
||||
@apply flex flex-col gap-2 px-2 py-2 border-b;
|
||||
border-color: var(--border-base);
|
||||
background-color: var(--surface-secondary);
|
||||
}
|
||||
|
||||
.git-change-commit-input-wrap {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.git-change-commit-input {
|
||||
@apply w-full min-h-[32px] px-2 py-1.5 pr-20 text-xs rounded border border-base resize-y;
|
||||
background-color: var(--surface-base);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.git-change-commit-input::placeholder {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.git-change-commit-button {
|
||||
@apply inline-flex items-center justify-center self-start px-3 py-1.5 text-xs font-medium rounded border border-base transition-colors;
|
||||
background-color: var(--surface-base);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.git-change-commit-button-overlay {
|
||||
position: absolute;
|
||||
bottom: 8px;
|
||||
right: 8px;
|
||||
z-index: 1;
|
||||
align-self: auto;
|
||||
padding: 0.25rem 0.5rem;
|
||||
}
|
||||
|
||||
.git-change-commit-button-overlay:focus-visible {
|
||||
outline: 2px solid var(--accent-primary);
|
||||
outline-offset: 1px;
|
||||
}
|
||||
|
||||
.git-change-commit-button-overlay:hover {
|
||||
background-color: var(--surface-hover);
|
||||
}
|
||||
|
||||
.git-change-commit-button:hover:not(:disabled) {
|
||||
background-color: var(--surface-hover);
|
||||
}
|
||||
|
||||
.git-change-commit-button:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.git-change-section {
|
||||
@apply border-b last:border-b-0;
|
||||
border-color: var(--border-base);
|
||||
}
|
||||
|
||||
.git-change-section-header {
|
||||
@apply w-full flex items-center justify-between gap-2 px-2 py-1 text-left;
|
||||
background-color: var(--surface-secondary);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.git-change-section-header:hover {
|
||||
background-color: var(--surface-hover);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.git-change-section-header-main {
|
||||
@apply flex items-center gap-2 min-w-0;
|
||||
}
|
||||
|
||||
.git-change-section-chevron {
|
||||
@apply inline-flex items-center justify-center shrink-0;
|
||||
}
|
||||
|
||||
.git-change-section-title {
|
||||
@apply text-[11px] font-semibold uppercase tracking-wide;
|
||||
}
|
||||
|
||||
.git-change-section-title-row {
|
||||
@apply inline-flex items-center gap-2 min-w-0 flex-wrap;
|
||||
}
|
||||
|
||||
.git-change-section-badge {
|
||||
flex-shrink: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.git-change-section-count {
|
||||
@apply text-[10px] px-1.5 py-0.5 rounded-full shrink-0;
|
||||
background-color: var(--surface-base);
|
||||
color: var(--text-muted);
|
||||
border: 1px solid var(--border-base);
|
||||
}
|
||||
|
||||
.git-change-section-items {
|
||||
@apply flex flex-col;
|
||||
}
|
||||
|
||||
.git-change-list-item {
|
||||
padding-inline-start: 0.25rem;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.git-change-list-item-right {
|
||||
@apply flex items-center shrink-0;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.git-change-list-item-actions-zone {
|
||||
@apply flex items-center justify-end;
|
||||
position: absolute;
|
||||
inset-block: 0;
|
||||
inset-inline-end: 0;
|
||||
width: 34px;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.git-change-list-item-actions {
|
||||
@apply flex items-center justify-center;
|
||||
width: 32px;
|
||||
}
|
||||
|
||||
.git-change-list-item .file-list-item-content {
|
||||
padding-inline-end: 2rem;
|
||||
}
|
||||
|
||||
.git-change-row-action {
|
||||
@apply inline-flex items-center justify-center w-5 h-5 rounded border border-base leading-none;
|
||||
background-color: var(--surface-base);
|
||||
border-color: var(--border-base);
|
||||
color: var(--text-secondary);
|
||||
padding: 0;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
transition: background-color 150ms ease, color 150ms ease, border-color 150ms ease;
|
||||
}
|
||||
|
||||
.git-change-row-action:hover {
|
||||
background-color: var(--surface-hover);
|
||||
border-color: var(--border-base);
|
||||
color: var(--text-primary);
|
||||
box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--text-primary) 10%, transparent);
|
||||
}
|
||||
|
||||
.file-list-item-active .git-change-row-action,
|
||||
.git-change-list-item-bulk-selected .git-change-row-action {
|
||||
background-color: color-mix(in srgb, var(--surface-base) 94%, white);
|
||||
border-color: color-mix(in srgb, var(--accent-primary) 24%, var(--border-base));
|
||||
}
|
||||
|
||||
.file-list-item-active .git-change-row-action:hover,
|
||||
.git-change-list-item-bulk-selected .git-change-row-action:hover {
|
||||
background-color: var(--surface-base);
|
||||
border-color: color-mix(in srgb, var(--accent-primary) 42%, var(--border-base));
|
||||
box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--accent-primary) 24%, transparent);
|
||||
}
|
||||
|
||||
.git-change-row-action:focus-visible {
|
||||
outline: 2px solid var(--accent-primary);
|
||||
outline-offset: 1px;
|
||||
}
|
||||
|
||||
.git-change-row-action-glyph {
|
||||
position: relative;
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.git-change-row-action-bar {
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
top: 50%;
|
||||
display: block;
|
||||
background-color: currentColor;
|
||||
border-radius: 999px;
|
||||
transform: translate(-50%, -50%);
|
||||
}
|
||||
|
||||
.git-change-row-action-bar-horizontal {
|
||||
width: 12px;
|
||||
height: 1.5px;
|
||||
}
|
||||
|
||||
.git-change-row-action-bar-vertical {
|
||||
width: 1.5px;
|
||||
height: 12px;
|
||||
}
|
||||
|
||||
@media (hover: none), (pointer: coarse) {
|
||||
.git-change-list-item-actions-zone {
|
||||
width: 34px;
|
||||
}
|
||||
}
|
||||
|
||||
.git-change-list-item-name {
|
||||
@apply text-[12px] leading-4 min-w-0 overflow-hidden whitespace-nowrap;
|
||||
color: var(--text-primary);
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.git-change-list-item-parent {
|
||||
@apply text-[10px] leading-3 min-w-0 overflow-hidden whitespace-nowrap;
|
||||
color: var(--text-muted);
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.git-change-context-widget-host {
|
||||
position: absolute;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
transform: translate(-50%, -50%);
|
||||
overflow: visible;
|
||||
z-index: 20;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.git-change-context-widget {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
border-radius: 999px;
|
||||
background-color: var(--accent-primary);
|
||||
box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--accent-primary) 70%, white 30%);
|
||||
color: white;
|
||||
font-size: 16px;
|
||||
line-height: 1;
|
||||
font-weight: 500;
|
||||
border: 0;
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.file-list-item-path {
|
||||
@apply text-xs font-mono min-w-0 flex-1 overflow-hidden whitespace-nowrap;
|
||||
color: var(--text-primary);
|
||||
@@ -335,6 +601,14 @@
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
direction: ltr;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.git-change-context-overlay {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
z-index: 30;
|
||||
}
|
||||
|
||||
.file-viewer-empty {
|
||||
@@ -507,6 +781,7 @@
|
||||
opacity: 0;
|
||||
transform: scale(0.95);
|
||||
}
|
||||
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
|
||||
1
packages/ui/src/types/global.d.ts
vendored
1
packages/ui/src/types/global.d.ts
vendored
@@ -37,6 +37,7 @@ declare global {
|
||||
id: string
|
||||
name: string
|
||||
baseUrl: string
|
||||
entryUrl?: string
|
||||
skipTlsVerify: boolean
|
||||
}) => Promise<{ ok: boolean }>
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user