Compare commits
15 Commits
main
...
feat/linux
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d0ddefc168 | ||
|
|
d456ae5837 | ||
|
|
3ec1598bbd | ||
|
|
ec3b418934 | ||
|
|
99b2066923 | ||
|
|
c2c88e956e | ||
|
|
aa69d2c1f1 | ||
|
|
e965754d4c | ||
|
|
efe5c455e0 | ||
|
|
be4f383602 | ||
|
|
adcaf3a116 | ||
|
|
a795869064 | ||
|
|
9bf4d351de | ||
|
|
657e78da6a | ||
|
|
dee356558f |
8
.github/workflows/build-and-upload.yml
vendored
8
.github/workflows/build-and-upload.yml
vendored
@@ -212,7 +212,7 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
shopt -s nullglob
|
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
|
[ -f "$file" ] || continue
|
||||||
echo "Uploading $file"
|
echo "Uploading $file"
|
||||||
gh release upload "$TAG" "$file" --clobber
|
gh release upload "$TAG" "$file" --clobber
|
||||||
@@ -313,7 +313,7 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
shopt -s nullglob
|
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
|
[ -f "$file" ] || continue
|
||||||
echo "Uploading $file"
|
echo "Uploading $file"
|
||||||
gh release upload "$TAG" "$file" --clobber
|
gh release upload "$TAG" "$file" --clobber
|
||||||
@@ -324,7 +324,9 @@ jobs:
|
|||||||
uses: actions/upload-artifact@v4
|
uses: actions/upload-artifact@v4
|
||||||
with:
|
with:
|
||||||
name: ${{ inputs.actions_artifacts_name_prefix }}electron-linux
|
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 }}
|
retention-days: ${{ inputs.actions_artifacts_retention_days }}
|
||||||
if-no-files-found: error
|
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**
|
- **🎙️ Voice Input & Speech**
|
||||||
- **🌳 Git Worktrees**
|
- **🌳 Git Worktrees**
|
||||||
- **💬 Rich Message Experience**
|
- **💬 Rich Message Experience**
|
||||||
|
- **🧩 SideCars**
|
||||||
- **⌨️ Command Palette**
|
- **⌨️ Command Palette**
|
||||||
- **📁 File System Browser**
|
- **📁 File System Browser**
|
||||||
- **🔐 Authentication & Security**
|
- **🔐 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
|
## Requirements
|
||||||
|
|
||||||
- **[OpenCode CLI](https://opencode.ai)** — must be installed and in your `PATH`
|
- **[OpenCode CLI](https://opencode.ai)** — must be installed and in your `PATH`
|
||||||
|
|||||||
1176
package-lock.json
generated
1176
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -29,6 +29,8 @@
|
|||||||
"google-auth-library": "^10.5.0"
|
"google-auth-library": "^10.5.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@esbuild/darwin-arm64": "^0.28.0",
|
||||||
|
"@rollup/rollup-darwin-arm64": "^4.60.2",
|
||||||
"baseline-browser-mapping": "^2.9.11"
|
"baseline-browser-mapping": "^2.9.11"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -147,6 +147,13 @@
|
|||||||
"x64",
|
"x64",
|
||||||
"arm64"
|
"arm64"
|
||||||
]
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"target": "AppImage",
|
||||||
|
"arch": [
|
||||||
|
"x64",
|
||||||
|
"arm64"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"artifactName": "CodeNomad-${version}-${os}-${arch}.${ext}",
|
"artifactName": "CodeNomad-${version}-${os}-${arch}.${ext}",
|
||||||
|
|||||||
@@ -81,6 +81,55 @@ export interface WorktreeMap {
|
|||||||
parentSessionWorktreeSlug: Record<string, string>
|
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 type LogLevel = "debug" | "info" | "warn" | "error"
|
||||||
|
|
||||||
export interface WorkspaceLogEntry {
|
export interface WorkspaceLogEntry {
|
||||||
@@ -288,6 +337,15 @@ export interface RemoteServerProbeResponse {
|
|||||||
errorCode?: string
|
errorCode?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface RemoteProxySessionCreateRequest {
|
||||||
|
baseUrl: string
|
||||||
|
skipTlsVerify?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RemoteProxySessionCreateResponse {
|
||||||
|
windowUrl: string
|
||||||
|
}
|
||||||
|
|
||||||
export type WorkspaceEventType =
|
export type WorkspaceEventType =
|
||||||
| "workspace.created"
|
| "workspace.created"
|
||||||
| "workspace.started"
|
| "workspace.started"
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import { launchInBrowser } from "./launcher"
|
|||||||
import { resolveUi } from "./ui/remote-ui"
|
import { resolveUi } from "./ui/remote-ui"
|
||||||
import { AuthManager, BOOTSTRAP_TOKEN_STDOUT_PREFIX, DEFAULT_AUTH_COOKIE_NAME, DEFAULT_AUTH_USERNAME } from "./auth/manager"
|
import { AuthManager, BOOTSTRAP_TOKEN_STDOUT_PREFIX, DEFAULT_AUTH_COOKIE_NAME, DEFAULT_AUTH_USERNAME } from "./auth/manager"
|
||||||
import { resolveHttpsOptions } from "./server/tls"
|
import { resolveHttpsOptions } from "./server/tls"
|
||||||
|
import { RemoteProxySessionManager } from "./server/remote-proxy"
|
||||||
import { resolveNetworkAddresses, resolveRemoteAddresses } from "./server/network-addresses"
|
import { resolveNetworkAddresses, resolveRemoteAddresses } from "./server/network-addresses"
|
||||||
import { startDevReleaseMonitor } from "./releases/dev-release-monitor"
|
import { startDevReleaseMonitor } from "./releases/dev-release-monitor"
|
||||||
import { SpeechService } from "./speech/service"
|
import { SpeechService } from "./speech/service"
|
||||||
@@ -383,6 +384,11 @@ async function main() {
|
|||||||
|
|
||||||
const clientConnectionManager = new ClientConnectionManager(logger.child({ component: "client-connections" }))
|
const clientConnectionManager = new ClientConnectionManager(logger.child({ component: "client-connections" }))
|
||||||
const pluginChannel = new PluginChannelManager(logger.child({ component: "plugin-channel" }))
|
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({
|
const voiceModeManager = new VoiceModeManager({
|
||||||
connections: clientConnectionManager,
|
connections: clientConnectionManager,
|
||||||
channel: pluginChannel,
|
channel: pluginChannel,
|
||||||
@@ -422,6 +428,7 @@ async function main() {
|
|||||||
clientConnectionManager,
|
clientConnectionManager,
|
||||||
pluginChannel,
|
pluginChannel,
|
||||||
voiceModeManager,
|
voiceModeManager,
|
||||||
|
remoteProxySessionManager,
|
||||||
uiStaticDir: uiResolution.uiStaticDir ?? DEFAULT_UI_STATIC_DIR,
|
uiStaticDir: uiResolution.uiStaticDir ?? DEFAULT_UI_STATIC_DIR,
|
||||||
uiDevServerUrl: uiResolution.uiDevServerUrl,
|
uiDevServerUrl: uiResolution.uiDevServerUrl,
|
||||||
logger,
|
logger,
|
||||||
@@ -447,6 +454,7 @@ async function main() {
|
|||||||
clientConnectionManager,
|
clientConnectionManager,
|
||||||
pluginChannel,
|
pluginChannel,
|
||||||
voiceModeManager,
|
voiceModeManager,
|
||||||
|
remoteProxySessionManager,
|
||||||
uiStaticDir: uiResolution.uiStaticDir ?? DEFAULT_UI_STATIC_DIR,
|
uiStaticDir: uiResolution.uiStaticDir ?? DEFAULT_UI_STATIC_DIR,
|
||||||
uiDevServerUrl: undefined,
|
uiDevServerUrl: undefined,
|
||||||
logger,
|
logger,
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import { fetch } from "undici"
|
|||||||
import type { Logger } from "../logger"
|
import type { Logger } from "../logger"
|
||||||
import { WorkspaceManager } from "../workspaces/manager"
|
import { WorkspaceManager } from "../workspaces/manager"
|
||||||
import { isValidWorktreeSlug, listWorktrees, resolveRepoRoot } from "../workspaces/git-worktrees"
|
import { isValidWorktreeSlug, listWorktrees, resolveRepoRoot } from "../workspaces/git-worktrees"
|
||||||
|
import { resolveWorktreeDirectory } from "../workspaces/worktree-directory"
|
||||||
|
|
||||||
import type { SettingsService } from "../settings/service"
|
import type { SettingsService } from "../settings/service"
|
||||||
import { FileSystemBrowser } from "../filesystem/browser"
|
import { FileSystemBrowser } from "../filesystem/browser"
|
||||||
@@ -25,6 +26,7 @@ import { registerBackgroundProcessRoutes } from "./routes/background-processes"
|
|||||||
import { registerWorktreeRoutes } from "./routes/worktrees"
|
import { registerWorktreeRoutes } from "./routes/worktrees"
|
||||||
import { registerSpeechRoutes } from "./routes/speech"
|
import { registerSpeechRoutes } from "./routes/speech"
|
||||||
import { registerRemoteServerRoutes } from "./routes/remote-servers"
|
import { registerRemoteServerRoutes } from "./routes/remote-servers"
|
||||||
|
import { registerRemoteProxyRoutes } from "./routes/remote-proxy"
|
||||||
import { registerSideCarRoutes } from "./routes/sidecars"
|
import { registerSideCarRoutes } from "./routes/sidecars"
|
||||||
import { ServerMeta } from "../api-types"
|
import { ServerMeta } from "../api-types"
|
||||||
import { InstanceStore } from "../storage/instance-store"
|
import { InstanceStore } from "../storage/instance-store"
|
||||||
@@ -37,6 +39,7 @@ import { ClientConnectionManager } from "../clients/connection-manager"
|
|||||||
import { PluginChannelManager } from "../plugins/channel"
|
import { PluginChannelManager } from "../plugins/channel"
|
||||||
import { VoiceModeManager } from "../plugins/voice-mode"
|
import { VoiceModeManager } from "../plugins/voice-mode"
|
||||||
import type { SideCarManager } from "../sidecars/manager"
|
import type { SideCarManager } from "../sidecars/manager"
|
||||||
|
import type { RemoteProxySessionManager } from "./remote-proxy"
|
||||||
|
|
||||||
interface HttpServerDeps {
|
interface HttpServerDeps {
|
||||||
bindHost: string
|
bindHost: string
|
||||||
@@ -57,6 +60,7 @@ interface HttpServerDeps {
|
|||||||
clientConnectionManager: ClientConnectionManager
|
clientConnectionManager: ClientConnectionManager
|
||||||
pluginChannel: PluginChannelManager
|
pluginChannel: PluginChannelManager
|
||||||
voiceModeManager: VoiceModeManager
|
voiceModeManager: VoiceModeManager
|
||||||
|
remoteProxySessionManager: RemoteProxySessionManager
|
||||||
uiStaticDir: string
|
uiStaticDir: string
|
||||||
uiDevServerUrl?: string
|
uiDevServerUrl?: string
|
||||||
logger: Logger
|
logger: Logger
|
||||||
@@ -273,6 +277,7 @@ export function createHttpServer(deps: HttpServerDeps) {
|
|||||||
workspaceManager: deps.workspaceManager,
|
workspaceManager: deps.workspaceManager,
|
||||||
})
|
})
|
||||||
registerRemoteServerRoutes(app, { logger: apiLogger })
|
registerRemoteServerRoutes(app, { logger: apiLogger })
|
||||||
|
registerRemoteProxyRoutes(app, { logger: proxyLogger, sessionManager: deps.remoteProxySessionManager })
|
||||||
registerSpeechRoutes(app, { speechService: deps.speechService })
|
registerSpeechRoutes(app, { speechService: deps.speechService })
|
||||||
registerSideCarRoutes(app, { sidecarManager: deps.sidecarManager })
|
registerSideCarRoutes(app, { sidecarManager: deps.sidecarManager })
|
||||||
registerSideCarProxyRoutes(app, { sidecarManager: deps.sidecarManager, logger: proxyLogger })
|
registerSideCarProxyRoutes(app, { sidecarManager: deps.sidecarManager, logger: proxyLogger })
|
||||||
@@ -760,52 +765,6 @@ function normalizeInstanceSuffix(pathSuffix: string | undefined) {
|
|||||||
return trimmed.length === 0 ? "/" : `/${trimmed}`
|
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) {
|
function setupStaticUi(app: FastifyInstance, uiDir: string, authManager: AuthManager) {
|
||||||
if (!uiDir) {
|
if (!uiDir) {
|
||||||
app.log.warn("UI static directory not provided; API endpoints only")
|
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)
|
||||||
|
}
|
||||||
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 { FastifyInstance, FastifyReply } from "fastify"
|
||||||
import { z } from "zod"
|
import { z } from "zod"
|
||||||
import { WorkspaceManager } from "../../workspaces/manager"
|
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 {
|
interface RouteDeps {
|
||||||
workspaceManager: WorkspaceManager
|
workspaceManager: WorkspaceManager
|
||||||
@@ -23,6 +27,20 @@ const WorkspaceFileContentBodySchema = z.object({
|
|||||||
contents: z.string(),
|
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({
|
const WorkspaceFileSearchQuerySchema = z.object({
|
||||||
q: z.string().trim().min(1, "Query is required"),
|
q: z.string().trim().min(1, "Query is required"),
|
||||||
limit: z.coerce.number().int().positive().max(200).optional(),
|
limit: z.coerce.number().int().positive().max(200).optional(),
|
||||||
@@ -118,10 +136,138 @@ export function registerWorkspaceRoutes(app: FastifyInstance, deps: RouteDeps) {
|
|||||||
return handleWorkspaceError(error, reply)
|
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) {
|
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") {
|
if (error instanceof Error && error.message === "Workspace not found") {
|
||||||
reply.code(404)
|
reply.code(404)
|
||||||
return { error: "Workspace not found" }
|
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 }
|
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> {
|
function runGit(args: string[], cwd: string): Promise<GitResult> {
|
||||||
return new Promise((resolve) => {
|
return new Promise((resolve) => {
|
||||||
const child = spawn("git", args, { cwd, stdio: ["ignore", "pipe", "pipe"] })
|
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 }> {
|
export async function resolveRepoRoot(folder: string, logger?: LogLike): Promise<{ repoRoot: string; isGitRepo: boolean }> {
|
||||||
const result = await runGit(["rev-parse", "--show-toplevel"], folder)
|
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) {
|
if (!result.ok) {
|
||||||
logger?.debug?.({ folder, err: result.error }, "Folder is not a Git repository; using workspace folder as root")
|
logger?.debug?.({ folder, err: result.error }, "Folder is not a Git repository; using workspace folder as root")
|
||||||
return { repoRoot: folder, isGitRepo: false }
|
return { repoRoot: folder, isGitRepo: false }
|
||||||
@@ -49,6 +56,11 @@ export async function resolveRepoRoot(folder: string, logger?: LogLike): Promise
|
|||||||
return { repoRoot, isGitRepo: true }
|
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 }> {
|
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 records: Array<{ worktree: string; branch?: string; head?: string; detached?: boolean }> = []
|
||||||
const lines = output.split(/\r?\n/)
|
const lines = output.split(/\r?\n/)
|
||||||
@@ -90,15 +102,22 @@ export async function listWorktrees(params: {
|
|||||||
logger?: LogLike
|
logger?: LogLike
|
||||||
}): Promise<WorktreeDescriptor[]> {
|
}): Promise<WorktreeDescriptor[]> {
|
||||||
const { repoRoot, workspaceFolder, logger } = params
|
const { repoRoot, workspaceFolder, logger } = params
|
||||||
const rootDescriptor: WorktreeDescriptor = { slug: "root", directory: repoRoot, kind: "root" }
|
|
||||||
|
|
||||||
const result = await runGit(["worktree", "list", "--porcelain"], workspaceFolder)
|
const result = await runGit(["worktree", "list", "--porcelain"], workspaceFolder)
|
||||||
if (!result.ok) {
|
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")
|
logger?.debug?.({ repoRoot, err: result.error }, "Failed to list git worktrees; returning root only")
|
||||||
return [rootDescriptor]
|
return [rootDescriptor]
|
||||||
}
|
}
|
||||||
|
|
||||||
const records = parseWorktreePorcelain(result.stdout)
|
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 worktrees: WorktreeDescriptor[] = [rootDescriptor]
|
||||||
const seen = new Set<string>(["root"])
|
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
|
||||||
|
}
|
||||||
515
packages/tauri-app/Cargo.lock
generated
515
packages/tauri-app/Cargo.lock
generated
@@ -47,6 +47,15 @@ version = "1.0.102"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c"
|
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]]
|
[[package]]
|
||||||
name = "async-broadcast"
|
name = "async-broadcast"
|
||||||
version = "0.7.2"
|
version = "0.7.2"
|
||||||
@@ -213,6 +222,105 @@ version = "1.5.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8"
|
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]]
|
[[package]]
|
||||||
name = "base64"
|
name = "base64"
|
||||||
version = "0.21.7"
|
version = "0.21.7"
|
||||||
@@ -408,6 +516,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "7a0dd1ca384932ff3641c8718a02769f1698e7563dc6974ffd03346116310423"
|
checksum = "7a0dd1ca384932ff3641c8718a02769f1698e7563dc6974ffd03346116310423"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"find-msvc-tools",
|
"find-msvc-tools",
|
||||||
|
"jobserver",
|
||||||
|
"libc",
|
||||||
"shlex",
|
"shlex",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -444,6 +554,12 @@ version = "1.0.4"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
|
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "cfg_aliases"
|
||||||
|
version = "0.2.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "chrono"
|
name = "chrono"
|
||||||
version = "0.4.44"
|
version = "0.4.44"
|
||||||
@@ -456,17 +572,34 @@ dependencies = [
|
|||||||
"windows-link 0.2.1",
|
"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]]
|
[[package]]
|
||||||
name = "codenomad-tauri"
|
name = "codenomad-tauri"
|
||||||
version = "0.14.0"
|
version = "0.14.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
|
"axum",
|
||||||
|
"axum-server",
|
||||||
|
"base64 0.22.1",
|
||||||
|
"bytes",
|
||||||
"dirs 5.0.1",
|
"dirs 5.0.1",
|
||||||
|
"futures-util",
|
||||||
"keepawake",
|
"keepawake",
|
||||||
"libc",
|
"libc",
|
||||||
"once_cell",
|
"once_cell",
|
||||||
"parking_lot",
|
"parking_lot",
|
||||||
|
"rand 0.8.5",
|
||||||
"regex",
|
"regex",
|
||||||
|
"reqwest 0.12.28",
|
||||||
|
"rustls",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"serde_yaml",
|
"serde_yaml",
|
||||||
@@ -477,7 +610,9 @@ dependencies = [
|
|||||||
"tauri-plugin-notification",
|
"tauri-plugin-notification",
|
||||||
"tauri-plugin-opener",
|
"tauri-plugin-opener",
|
||||||
"thiserror 1.0.69",
|
"thiserror 1.0.69",
|
||||||
|
"tokio",
|
||||||
"url",
|
"url",
|
||||||
|
"webkit2gtk",
|
||||||
"which",
|
"which",
|
||||||
"windows-sys 0.59.0",
|
"windows-sys 0.59.0",
|
||||||
]
|
]
|
||||||
@@ -969,6 +1104,15 @@ version = "1.2.2"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "4ef6b89e5b37196644d8796de5268852ff179b44e96276cf4290264843743bb7"
|
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]]
|
[[package]]
|
||||||
name = "endi"
|
name = "endi"
|
||||||
version = "1.1.1"
|
version = "1.1.1"
|
||||||
@@ -1139,6 +1283,22 @@ dependencies = [
|
|||||||
"percent-encoding",
|
"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]]
|
[[package]]
|
||||||
name = "futf"
|
name = "futf"
|
||||||
version = "0.1.5"
|
version = "0.1.5"
|
||||||
@@ -1379,8 +1539,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0"
|
checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"cfg-if",
|
"cfg-if",
|
||||||
|
"js-sys",
|
||||||
"libc",
|
"libc",
|
||||||
"wasi 0.11.1+wasi-snapshot-preview1",
|
"wasi 0.11.1+wasi-snapshot-preview1",
|
||||||
|
"wasm-bindgen",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -1390,9 +1552,11 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd"
|
checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"cfg-if",
|
"cfg-if",
|
||||||
|
"js-sys",
|
||||||
"libc",
|
"libc",
|
||||||
"r-efi 5.3.0",
|
"r-efi 5.3.0",
|
||||||
"wasip2",
|
"wasip2",
|
||||||
|
"wasm-bindgen",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -1574,6 +1738,25 @@ dependencies = [
|
|||||||
"syn 2.0.117",
|
"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]]
|
[[package]]
|
||||||
name = "hashbrown"
|
name = "hashbrown"
|
||||||
version = "0.12.3"
|
version = "0.12.3"
|
||||||
@@ -1689,6 +1872,12 @@ version = "1.10.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87"
|
checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "httpdate"
|
||||||
|
version = "1.0.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "hyper"
|
name = "hyper"
|
||||||
version = "1.8.1"
|
version = "1.8.1"
|
||||||
@@ -1699,9 +1888,11 @@ dependencies = [
|
|||||||
"bytes",
|
"bytes",
|
||||||
"futures-channel",
|
"futures-channel",
|
||||||
"futures-core",
|
"futures-core",
|
||||||
|
"h2",
|
||||||
"http",
|
"http",
|
||||||
"http-body",
|
"http-body",
|
||||||
"httparse",
|
"httparse",
|
||||||
|
"httpdate",
|
||||||
"itoa",
|
"itoa",
|
||||||
"pin-project-lite",
|
"pin-project-lite",
|
||||||
"pin-utils",
|
"pin-utils",
|
||||||
@@ -1710,6 +1901,23 @@ dependencies = [
|
|||||||
"want",
|
"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]]
|
[[package]]
|
||||||
name = "hyper-util"
|
name = "hyper-util"
|
||||||
version = "0.1.20"
|
version = "0.1.20"
|
||||||
@@ -1999,6 +2207,16 @@ version = "0.3.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "8eaf4bc02d17cbdd7ff4c7438cafcdf7fb9a4613313ad11b4f8fefe7d3fa0130"
|
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]]
|
[[package]]
|
||||||
name = "js-sys"
|
name = "js-sys"
|
||||||
version = "0.3.91"
|
version = "0.3.91"
|
||||||
@@ -2157,6 +2375,12 @@ version = "0.4.29"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897"
|
checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "lru-slab"
|
||||||
|
version = "0.1.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "mac"
|
name = "mac"
|
||||||
version = "0.1.1"
|
version = "0.1.1"
|
||||||
@@ -2217,6 +2441,12 @@ version = "0.1.10"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "2532096657941c2fea9c289d370a250971c689d4f143798ff67113ec042024a5"
|
checksum = "2532096657941c2fea9c289d370a250971c689d4f143798ff67113ec042024a5"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "matchit"
|
||||||
|
version = "0.7.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "0e7465ac9959cc2b1404e8e2367b43684a6d13790fe23056cc8c6c5a6b7bcb94"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "memchr"
|
name = "memchr"
|
||||||
version = "2.8.0"
|
version = "2.8.0"
|
||||||
@@ -2995,6 +3225,61 @@ dependencies = [
|
|||||||
"memchr",
|
"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]]
|
[[package]]
|
||||||
name = "quote"
|
name = "quote"
|
||||||
version = "1.0.45"
|
version = "1.0.45"
|
||||||
@@ -3212,6 +3497,50 @@ version = "0.8.10"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a"
|
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]]
|
[[package]]
|
||||||
name = "reqwest"
|
name = "reqwest"
|
||||||
version = "0.13.2"
|
version = "0.13.2"
|
||||||
@@ -3242,7 +3571,7 @@ dependencies = [
|
|||||||
"url",
|
"url",
|
||||||
"wasm-bindgen",
|
"wasm-bindgen",
|
||||||
"wasm-bindgen-futures",
|
"wasm-bindgen-futures",
|
||||||
"wasm-streams",
|
"wasm-streams 0.5.0",
|
||||||
"web-sys",
|
"web-sys",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -3270,6 +3599,20 @@ dependencies = [
|
|||||||
"windows-sys 0.60.2",
|
"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]]
|
[[package]]
|
||||||
name = "rustc-hash"
|
name = "rustc-hash"
|
||||||
version = "2.1.1"
|
version = "2.1.1"
|
||||||
@@ -3311,6 +3654,53 @@ dependencies = [
|
|||||||
"windows-sys 0.61.2",
|
"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]]
|
[[package]]
|
||||||
name = "rustversion"
|
name = "rustversion"
|
||||||
version = "1.0.22"
|
version = "1.0.22"
|
||||||
@@ -3502,6 +3892,17 @@ dependencies = [
|
|||||||
"zmij",
|
"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]]
|
[[package]]
|
||||||
name = "serde_repr"
|
name = "serde_repr"
|
||||||
version = "0.1.20"
|
version = "0.1.20"
|
||||||
@@ -3531,6 +3932,18 @@ dependencies = [
|
|||||||
"serde_core",
|
"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]]
|
[[package]]
|
||||||
name = "serde_with"
|
name = "serde_with"
|
||||||
version = "3.18.0"
|
version = "3.18.0"
|
||||||
@@ -3792,6 +4205,12 @@ version = "0.11.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
|
checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "subtle"
|
||||||
|
version = "2.6.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "swift-rs"
|
name = "swift-rs"
|
||||||
version = "1.0.7"
|
version = "1.0.7"
|
||||||
@@ -3943,7 +4362,7 @@ dependencies = [
|
|||||||
"percent-encoding",
|
"percent-encoding",
|
||||||
"plist",
|
"plist",
|
||||||
"raw-window-handle",
|
"raw-window-handle",
|
||||||
"reqwest",
|
"reqwest 0.13.2",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"serde_repr",
|
"serde_repr",
|
||||||
@@ -4367,6 +4786,21 @@ dependencies = [
|
|||||||
"zerovec",
|
"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]]
|
[[package]]
|
||||||
name = "tokio"
|
name = "tokio"
|
||||||
version = "1.50.0"
|
version = "1.50.0"
|
||||||
@@ -4378,9 +4812,31 @@ dependencies = [
|
|||||||
"mio",
|
"mio",
|
||||||
"pin-project-lite",
|
"pin-project-lite",
|
||||||
"socket2",
|
"socket2",
|
||||||
|
"tokio-macros",
|
||||||
"windows-sys 0.61.2",
|
"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]]
|
[[package]]
|
||||||
name = "tokio-util"
|
name = "tokio-util"
|
||||||
version = "0.7.18"
|
version = "0.7.18"
|
||||||
@@ -4512,6 +4968,7 @@ dependencies = [
|
|||||||
"tokio",
|
"tokio",
|
||||||
"tower-layer",
|
"tower-layer",
|
||||||
"tower-service",
|
"tower-service",
|
||||||
|
"tracing",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -4550,6 +5007,7 @@ version = "0.1.44"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100"
|
checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
"log",
|
||||||
"pin-project-lite",
|
"pin-project-lite",
|
||||||
"tracing-attributes",
|
"tracing-attributes",
|
||||||
"tracing-core",
|
"tracing-core",
|
||||||
@@ -4691,6 +5149,12 @@ version = "0.2.11"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "673aac59facbab8a9007c7f6108d11f63b603f7cabff99fabf650fea5c32b861"
|
checksum = "673aac59facbab8a9007c7f6108d11f63b603f7cabff99fabf650fea5c32b861"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "untrusted"
|
||||||
|
version = "0.9.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "url"
|
name = "url"
|
||||||
version = "2.5.8"
|
version = "2.5.8"
|
||||||
@@ -4902,6 +5366,19 @@ dependencies = [
|
|||||||
"wasmparser",
|
"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]]
|
[[package]]
|
||||||
name = "wasm-streams"
|
name = "wasm-streams"
|
||||||
version = "0.5.0"
|
version = "0.5.0"
|
||||||
@@ -4937,6 +5414,16 @@ dependencies = [
|
|||||||
"wasm-bindgen",
|
"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]]
|
[[package]]
|
||||||
name = "web_atoms"
|
name = "web_atoms"
|
||||||
version = "0.2.3"
|
version = "0.2.3"
|
||||||
@@ -4993,6 +5480,15 @@ dependencies = [
|
|||||||
"system-deps",
|
"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]]
|
[[package]]
|
||||||
name = "webview2-com"
|
name = "webview2-com"
|
||||||
version = "0.38.2"
|
version = "0.38.2"
|
||||||
@@ -5286,6 +5782,15 @@ dependencies = [
|
|||||||
"windows-targets 0.48.5",
|
"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]]
|
[[package]]
|
||||||
name = "windows-sys"
|
name = "windows-sys"
|
||||||
version = "0.59.0"
|
version = "0.59.0"
|
||||||
@@ -5927,6 +6432,12 @@ dependencies = [
|
|||||||
"synstructure",
|
"synstructure",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "zeroize"
|
||||||
|
version = "1.8.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "zerotrie"
|
name = "zerotrie"
|
||||||
version = "0.2.3"
|
version = "0.2.3"
|
||||||
|
|||||||
@@ -14,6 +14,7 @@
|
|||||||
"build": "tauri build"
|
"build": "tauri build"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@tauri-apps/cli": "^2.9.4"
|
"@tauri-apps/cli": "^2.9.4",
|
||||||
|
"@tauri-apps/cli-darwin-arm64": "^2.9.4"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,11 +12,20 @@ tauri = { version = "2.5.2", features = [ "devtools"] }
|
|||||||
serde = { version = "1", features = ["derive"] }
|
serde = { version = "1", features = ["derive"] }
|
||||||
serde_json = "1"
|
serde_json = "1"
|
||||||
serde_yaml = "0.9"
|
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"
|
regex = "1"
|
||||||
once_cell = "1"
|
once_cell = "1"
|
||||||
parking_lot = "0.12"
|
parking_lot = "0.12"
|
||||||
thiserror = "1"
|
thiserror = "1"
|
||||||
anyhow = "1"
|
anyhow = "1"
|
||||||
|
tokio = { version = "1", features = ["macros", "rt-multi-thread", "net", "sync"] }
|
||||||
which = "4"
|
which = "4"
|
||||||
libc = "0.2"
|
libc = "0.2"
|
||||||
keepawake = "0.6"
|
keepawake = "0.6"
|
||||||
@@ -28,4 +37,7 @@ url = "2"
|
|||||||
tauri-plugin-notification = "2"
|
tauri-plugin-notification = "2"
|
||||||
|
|
||||||
[target.'cfg(windows)'.dependencies]
|
[target.'cfg(windows)'.dependencies]
|
||||||
windows-sys = { version = "0.59", features = ["Win32_Foundation", "Win32_UI_Shell", "Win32_Security", "Win32_System_JobObjects"] }
|
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}"))
|
||||||
|
}
|
||||||
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")]
|
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
|
||||||
|
|
||||||
|
#[allow(dead_code)]
|
||||||
|
mod cert_manager;
|
||||||
mod cli_manager;
|
mod cli_manager;
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
mod linux_tls;
|
||||||
|
|
||||||
use cli_manager::{CliProcessManager, CliStatus};
|
use cli_manager::{CliProcessManager, CliStatus};
|
||||||
use keepawake::KeepAwake;
|
use keepawake::KeepAwake;
|
||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
use serde_json::json;
|
use serde_json::json;
|
||||||
use std::collections::HashMap;
|
use std::collections::{HashMap, HashSet};
|
||||||
use std::sync::atomic::{AtomicBool, Ordering};
|
use std::sync::atomic::{AtomicBool, Ordering};
|
||||||
use std::sync::Mutex;
|
use std::sync::Mutex;
|
||||||
use std::time::{SystemTime, UNIX_EPOCH};
|
use std::time::{SystemTime, UNIX_EPOCH};
|
||||||
@@ -45,6 +49,8 @@ pub struct AppState {
|
|||||||
pub wake_lock: Mutex<Option<KeepAwake>>,
|
pub wake_lock: Mutex<Option<KeepAwake>>,
|
||||||
pub zoom_level: Mutex<f64>,
|
pub zoom_level: Mutex<f64>,
|
||||||
pub remote_origins: Mutex<HashMap<String, String>>,
|
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)]
|
#[derive(Debug, Deserialize)]
|
||||||
@@ -53,6 +59,8 @@ struct RemoteWindowPayload {
|
|||||||
id: String,
|
id: String,
|
||||||
name: String,
|
name: String,
|
||||||
base_url: String,
|
base_url: String,
|
||||||
|
entry_url: Option<String>,
|
||||||
|
#[allow(dead_code)]
|
||||||
skip_tls_verify: bool,
|
skip_tls_verify: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -119,7 +127,7 @@ fn is_dev_mode() -> bool {
|
|||||||
|
|
||||||
fn should_allow_internal(url: &Url) -> bool {
|
fn should_allow_internal(url: &Url) -> bool {
|
||||||
match url.scheme() {
|
match url.scheme() {
|
||||||
"tauri" | "asset" | "file" => true,
|
"tauri" | "asset" | "file" | "about" => true,
|
||||||
// On Windows/WebView2, Tauri serves the app assets from `tauri.localhost`.
|
// On Windows/WebView2, Tauri serves the app assets from `tauri.localhost`.
|
||||||
// This must be treated as an internal origin or the navigation guard will
|
// 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.
|
// 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
|
false
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
async fn open_remote_window_impl(
|
||||||
fn open_remote_window(app: AppHandle, payload: RemoteWindowPayload) -> Result<(), String> {
|
app: AppHandle,
|
||||||
if payload.skip_tls_verify && payload.base_url.starts_with("https://") {
|
payload: RemoteWindowPayload,
|
||||||
return Err(
|
) -> Result<(), String> {
|
||||||
"Tauri cannot bypass self-signed HTTPS certificates automatically yet. Trust the certificate in your OS first, then reconnect, or use the CodeNomad Electron app."
|
let entry_url = payload.entry_url.as_deref().unwrap_or(payload.base_url.as_str());
|
||||||
.to_string(),
|
let parsed = Url::parse(entry_url).map_err(|err| err.to_string())?;
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
let parsed = Url::parse(&payload.base_url).map_err(|err| err.to_string())?;
|
|
||||||
let label = format!("remote-{}", payload.id);
|
let label = format!("remote-{}", payload.id);
|
||||||
let title = format!(
|
let title = format!(
|
||||||
"{} - {}",
|
"{} - {}",
|
||||||
payload.name,
|
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) {
|
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.set_title(&title);
|
||||||
let _ = existing.show();
|
let _ = existing.show();
|
||||||
let _ = existing.unminimize();
|
let _ = existing.unminimize();
|
||||||
@@ -193,25 +216,44 @@ fn open_remote_window(app: AppHandle, payload: RemoteWindowPayload) -> Result<()
|
|||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
|
|
||||||
app.state::<AppState>()
|
#[cfg(target_os = "linux")]
|
||||||
.remote_origins
|
let initial_url = if linux_tls::should_bootstrap_tls_navigation(&window_url, payload.skip_tls_verify)
|
||||||
.lock()
|
{
|
||||||
.map_err(|err| err.to_string())?
|
Url::parse("about:blank").map_err(|err| err.to_string())?
|
||||||
.insert(label.clone(), parsed.origin().ascii_serialization());
|
} else {
|
||||||
|
window_url.clone()
|
||||||
|
};
|
||||||
|
|
||||||
let window =
|
#[cfg(not(target_os = "linux"))]
|
||||||
WebviewWindowBuilder::new(&app, label.clone(), WebviewUrl::External(parsed.clone()))
|
let initial_url = window_url.clone();
|
||||||
.title(title)
|
|
||||||
.inner_size(1400.0, 900.0)
|
let window = WebviewWindowBuilder::new(&app, label.clone(), WebviewUrl::External(initial_url.clone()))
|
||||||
.min_inner_size(800.0, 600.0)
|
.title(title)
|
||||||
.build()
|
.inner_size(1400.0, 900.0)
|
||||||
.map_err(|err| err.to_string())?;
|
.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 app_handle = app.clone();
|
||||||
|
let label_for_cleanup = label.clone();
|
||||||
window.on_window_event(move |event| {
|
window.on_window_event(move |event| {
|
||||||
if let WindowEvent::Destroyed = event {
|
if let WindowEvent::Destroyed = event {
|
||||||
if let Ok(mut origins) = app_handle.state::<AppState>().remote_origins.lock() {
|
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(())
|
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> {
|
fn collect_directory_paths(paths: &[std::path::PathBuf]) -> Vec<String> {
|
||||||
paths
|
paths
|
||||||
.iter()
|
.iter()
|
||||||
@@ -346,6 +411,8 @@ fn set_windows_app_user_model_id() {
|
|||||||
fn set_windows_app_user_model_id() {}
|
fn set_windows_app_user_model_id() {}
|
||||||
|
|
||||||
fn main() {
|
fn main() {
|
||||||
|
let _ = rustls::crypto::ring::default_provider().install_default();
|
||||||
|
|
||||||
let navigation_guard: TauriPlugin<Wry, ()> = PluginBuilder::new("external-link-guard")
|
let navigation_guard: TauriPlugin<Wry, ()> = PluginBuilder::new("external-link-guard")
|
||||||
.on_navigation(|webview, url| intercept_navigation(webview, url))
|
.on_navigation(|webview, url| intercept_navigation(webview, url))
|
||||||
.build();
|
.build();
|
||||||
@@ -373,6 +440,8 @@ fn main() {
|
|||||||
wake_lock: Mutex::new(None),
|
wake_lock: Mutex::new(None),
|
||||||
zoom_level: Mutex::new(DEFAULT_ZOOM_LEVEL),
|
zoom_level: Mutex::new(DEFAULT_ZOOM_LEVEL),
|
||||||
remote_origins: Mutex::new(HashMap::new()),
|
remote_origins: Mutex::new(HashMap::new()),
|
||||||
|
remote_skip_tls_verify: Mutex::new(HashMap::new()),
|
||||||
|
remote_tls_handlers: Mutex::new(HashSet::new()),
|
||||||
})
|
})
|
||||||
.setup(|app| {
|
.setup(|app| {
|
||||||
set_windows_app_user_model_id();
|
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,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 { loadMonaco } from "../../lib/monaco/setup"
|
||||||
import { getOrCreateTextModel } from "../../lib/monaco/model-cache"
|
import { getOrCreateTextModel } from "../../lib/monaco/model-cache"
|
||||||
import { inferMonacoLanguageId } from "../../lib/monaco/language"
|
import { inferMonacoLanguageId } from "../../lib/monaco/language"
|
||||||
@@ -15,6 +15,8 @@ interface MonacoDiffViewerProps {
|
|||||||
viewMode?: "split" | "unified"
|
viewMode?: "split" | "unified"
|
||||||
contextMode?: "expanded" | "collapsed"
|
contextMode?: "expanded" | "collapsed"
|
||||||
wordWrap?: "on" | "off"
|
wordWrap?: "on" | "off"
|
||||||
|
onRequestInsertContext?: (selection: { startLine: number; endLine: number }) => void
|
||||||
|
insertContextLabel?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export function MonacoDiffViewer(props: MonacoDiffViewerProps) {
|
export function MonacoDiffViewer(props: MonacoDiffViewerProps) {
|
||||||
@@ -24,6 +26,10 @@ export function MonacoDiffViewer(props: MonacoDiffViewerProps) {
|
|||||||
let diffEditor: any = null
|
let diffEditor: any = null
|
||||||
let monaco: any = null
|
let monaco: any = null
|
||||||
const [ready, setReady] = createSignal(false)
|
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(() => {
|
const resolvedContent = createMemo(() => {
|
||||||
if (props.patch !== undefined && props.patch !== null) {
|
if (props.patch !== undefined && props.patch !== null) {
|
||||||
@@ -49,6 +55,52 @@ export function MonacoDiffViewer(props: MonacoDiffViewerProps) {
|
|||||||
diffEditor = null
|
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(() => {
|
onMount(() => {
|
||||||
let cancelled = false
|
let cancelled = false
|
||||||
void (async () => {
|
void (async () => {
|
||||||
@@ -68,7 +120,7 @@ export function MonacoDiffViewer(props: MonacoDiffViewerProps) {
|
|||||||
renderWhitespace: "selection",
|
renderWhitespace: "selection",
|
||||||
fontSize: 13,
|
fontSize: 13,
|
||||||
wordWrap: props.wordWrap === "on" ? "on" : "off",
|
wordWrap: props.wordWrap === "on" ? "on" : "off",
|
||||||
glyphMargin: false,
|
glyphMargin: true,
|
||||||
folding: false,
|
folding: false,
|
||||||
// Keep enough gutter space so unified diffs don't overlap `+`/`-` markers.
|
// Keep enough gutter space so unified diffs don't overlap `+`/`-` markers.
|
||||||
lineNumbersMinChars: 4,
|
lineNumbersMinChars: 4,
|
||||||
@@ -81,6 +133,8 @@ export function MonacoDiffViewer(props: MonacoDiffViewerProps) {
|
|||||||
})
|
})
|
||||||
|
|
||||||
setReady(true)
|
setReady(true)
|
||||||
|
|
||||||
|
layoutInsertWidget()
|
||||||
})()
|
})()
|
||||||
|
|
||||||
onCleanup(() => {
|
onCleanup(() => {
|
||||||
@@ -95,6 +149,74 @@ export function MonacoDiffViewer(props: MonacoDiffViewerProps) {
|
|||||||
monaco.editor.setTheme(isDark() ? "vs-dark" : "vs")
|
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(() => {
|
createEffect(() => {
|
||||||
if (!ready() || !monaco || !diffEditor) return
|
if (!ready() || !monaco || !diffEditor) return
|
||||||
const viewMode = props.viewMode === "unified" ? "unified" : "split"
|
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 { openSettings, settingsOpen } from "../stores/settings-screen"
|
||||||
import { openExternalUrl } from "../lib/external-url"
|
import { openExternalUrl } from "../lib/external-url"
|
||||||
import { serverApi } from "../lib/api-client"
|
import { serverApi } from "../lib/api-client"
|
||||||
|
import { runtimeEnv } from "../lib/runtime-env"
|
||||||
import { openRemoteServerWindow } from "../lib/native/remote-window"
|
import { openRemoteServerWindow } from "../lib/native/remote-window"
|
||||||
|
|
||||||
const codeNomadLogo = new URL("../images/CodeNomad-Icon.png", import.meta.url).href
|
const codeNomadLogo = new URL("../images/CodeNomad-Icon.png", import.meta.url).href
|
||||||
@@ -332,7 +333,15 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
if (openWindow) {
|
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)
|
await markRemoteServerConnected(profile.id)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -43,6 +43,7 @@ import RightPanel from "./shell/right-panel/RightPanel"
|
|||||||
import { useDrawerChrome } from "./shell/useDrawerChrome"
|
import { useDrawerChrome } from "./shell/useDrawerChrome"
|
||||||
import { getRetrySeconds, getSessionRetry, getSessionStatus } from "../../stores/session-status"
|
import { getRetrySeconds, getSessionRetry, getSessionStatus } from "../../stores/session-status"
|
||||||
import { Maximize2, ShieldAlert } from "lucide-solid"
|
import { Maximize2, ShieldAlert } from "lucide-solid"
|
||||||
|
import type { PromptInputApi } from "../prompt-input/types"
|
||||||
|
|
||||||
import type { LayoutMode } from "./shell/types"
|
import type { LayoutMode } from "./shell/types"
|
||||||
import {
|
import {
|
||||||
@@ -105,6 +106,7 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
|
|||||||
const [showBackgroundOutput, setShowBackgroundOutput] = createSignal(false)
|
const [showBackgroundOutput, setShowBackgroundOutput] = createSignal(false)
|
||||||
const [permissionModalOpen, setPermissionModalOpen] = createSignal(false)
|
const [permissionModalOpen, setPermissionModalOpen] = createSignal(false)
|
||||||
const [now, setNow] = createSignal(Date.now())
|
const [now, setNow] = createSignal(Date.now())
|
||||||
|
const [sessionPromptApis, setSessionPromptApis] = createSignal<Record<string, PromptInputApi | null>>({})
|
||||||
|
|
||||||
// Worktree selector manages its own dialogs.
|
// Worktree selector manages its own dialogs.
|
||||||
const [showSessionSearch, setShowSessionSearch] = createSignal(false)
|
const [showSessionSearch, setShowSessionSearch] = createSignal(false)
|
||||||
@@ -268,6 +270,19 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
|
|||||||
|
|
||||||
const permissionQueue = createMemo(() => getPermissionQueue(props.instance.id))
|
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(() => {
|
createEffect(() => {
|
||||||
getPermissionAutoAcceptInFlightVersion()
|
getPermissionAutoAcceptInFlightVersion()
|
||||||
|
|
||||||
@@ -594,6 +609,7 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
|
|||||||
onCloseRightDrawer={closeRightDrawer}
|
onCloseRightDrawer={closeRightDrawer}
|
||||||
onPinRightDrawer={pinRightDrawer}
|
onPinRightDrawer={pinRightDrawer}
|
||||||
onUnpinRightDrawer={unpinRightDrawer}
|
onUnpinRightDrawer={unpinRightDrawer}
|
||||||
|
promptInputApi={activePromptInputApi}
|
||||||
setContentEl={setRightDrawerContentEl}
|
setContentEl={setRightDrawerContentEl}
|
||||||
/>
|
/>
|
||||||
</Box>
|
</Box>
|
||||||
@@ -656,6 +672,7 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
|
|||||||
onCloseRightDrawer={closeRightDrawer}
|
onCloseRightDrawer={closeRightDrawer}
|
||||||
onPinRightDrawer={pinRightDrawer}
|
onPinRightDrawer={pinRightDrawer}
|
||||||
onUnpinRightDrawer={unpinRightDrawer}
|
onUnpinRightDrawer={unpinRightDrawer}
|
||||||
|
promptInputApi={activePromptInputApi}
|
||||||
setContentEl={setRightDrawerContentEl}
|
setContentEl={setRightDrawerContentEl}
|
||||||
/>
|
/>
|
||||||
</Drawer>
|
</Drawer>
|
||||||
@@ -892,6 +909,7 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
|
|||||||
escapeInDebounce={props.escapeInDebounce}
|
escapeInDebounce={props.escapeInDebounce}
|
||||||
isPhoneLayout={isPhoneLayout()}
|
isPhoneLayout={isPhoneLayout()}
|
||||||
compactPromptLayout={compactPromptLayout()}
|
compactPromptLayout={compactPromptLayout()}
|
||||||
|
registerSessionPromptApi={registerSessionPromptApi}
|
||||||
showSidebarToggle={showEmbeddedSidebarToggle()}
|
showSidebarToggle={showEmbeddedSidebarToggle()}
|
||||||
onSidebarToggle={() => setLeftOpen(true)}
|
onSidebarToggle={() => setLeftOpen(true)}
|
||||||
forceCompactStatusLayout={showEmbeddedSidebarToggle()}
|
forceCompactStatusLayout={showEmbeddedSidebarToggle()}
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import {
|
|||||||
type Component,
|
type Component,
|
||||||
} from "solid-js"
|
} from "solid-js"
|
||||||
import type { ToolState } from "@opencode-ai/sdk/v2"
|
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 IconButton from "@suid/material/IconButton"
|
||||||
import MenuOpenIcon from "@suid/icons-material/MenuOpen"
|
import MenuOpenIcon from "@suid/icons-material/MenuOpen"
|
||||||
import PushPinIcon from "@suid/icons-material/PushPin"
|
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 { Instance } from "../../../../types/instance"
|
||||||
import type { BackgroundProcess } from "../../../../../../server/src/api-types"
|
import type { BackgroundProcess } from "../../../../../../server/src/api-types"
|
||||||
import type { Session } from "../../../../types/session"
|
import type { Session } from "../../../../types/session"
|
||||||
|
import type { PromptInputApi } from "../../../prompt-input/types"
|
||||||
import type { DrawerViewState } from "../types"
|
import type { DrawerViewState } from "../types"
|
||||||
import type { DiffContextMode, DiffViewMode, DiffWordWrapMode, RightPanelTab } 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 { requestData } from "../../../../lib/opencode-api"
|
||||||
import { serverApi } from "../../../../lib/api-client"
|
import { serverApi } from "../../../../lib/api-client"
|
||||||
import { showConfirmDialog } from "../../../../stores/alerts"
|
import { showConfirmDialog } from "../../../../stores/alerts"
|
||||||
import { showToastNotification } from "../../../../lib/notifications"
|
import { showToastNotification } from "../../../../lib/notifications"
|
||||||
import { buildUnifiedDiffFromSdkPatch, tryReverseApplyUnifiedDiff } from "../../../../lib/unified-diff-reverse"
|
|
||||||
import { useGlobalPointerDrag } from "../useGlobalPointerDrag"
|
import { useGlobalPointerDrag } from "../useGlobalPointerDrag"
|
||||||
|
import { useGitChanges } from "./useGitChanges"
|
||||||
import {
|
import {
|
||||||
RIGHT_PANEL_CHANGES_DIFF_CONTEXT_MODE_KEY,
|
RIGHT_PANEL_CHANGES_DIFF_CONTEXT_MODE_KEY,
|
||||||
RIGHT_PANEL_CHANGES_DIFF_VIEW_MODE_KEY,
|
RIGHT_PANEL_CHANGES_DIFF_VIEW_MODE_KEY,
|
||||||
@@ -41,7 +48,11 @@ import {
|
|||||||
RIGHT_PANEL_FILES_SPLIT_WIDTH_KEY,
|
RIGHT_PANEL_FILES_SPLIT_WIDTH_KEY,
|
||||||
RIGHT_PANEL_GIT_CHANGES_LIST_OPEN_NONPHONE_KEY,
|
RIGHT_PANEL_GIT_CHANGES_LIST_OPEN_NONPHONE_KEY,
|
||||||
RIGHT_PANEL_GIT_CHANGES_LIST_OPEN_PHONE_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_SPLIT_WIDTH_KEY,
|
||||||
|
RIGHT_PANEL_GIT_CHANGES_UNSTAGED_OPEN_NONPHONE_KEY,
|
||||||
|
RIGHT_PANEL_GIT_CHANGES_UNSTAGED_OPEN_PHONE_KEY,
|
||||||
RIGHT_PANEL_TAB_STORAGE_KEY,
|
RIGHT_PANEL_TAB_STORAGE_KEY,
|
||||||
readStoredBool,
|
readStoredBool,
|
||||||
readStoredEnum,
|
readStoredEnum,
|
||||||
@@ -82,6 +93,7 @@ interface RightPanelProps {
|
|||||||
onCloseRightDrawer: () => void
|
onCloseRightDrawer: () => void
|
||||||
onPinRightDrawer: () => void
|
onPinRightDrawer: () => void
|
||||||
onUnpinRightDrawer: () => void
|
onUnpinRightDrawer: () => void
|
||||||
|
promptInputApi: Accessor<PromptInputApi | null>
|
||||||
|
|
||||||
setContentEl: (el: HTMLElement | null) => void
|
setContentEl: (el: HTMLElement | null) => void
|
||||||
}
|
}
|
||||||
@@ -133,6 +145,8 @@ const RightPanel: Component<RightPanelProps> = (props) => {
|
|||||||
const [changesListTouched, setChangesListTouched] = createSignal(false)
|
const [changesListTouched, setChangesListTouched] = createSignal(false)
|
||||||
const [gitChangesListOpen, setGitChangesListOpen] = createSignal(true)
|
const [gitChangesListOpen, setGitChangesListOpen] = createSignal(true)
|
||||||
const [gitChangesListTouched, setGitChangesListTouched] = createSignal(false)
|
const [gitChangesListTouched, setGitChangesListTouched] = createSignal(false)
|
||||||
|
const [gitStagedOpen, setGitStagedOpen] = createSignal(true)
|
||||||
|
const [gitUnstagedOpen, setGitUnstagedOpen] = createSignal(true)
|
||||||
|
|
||||||
const listLayoutKey = createMemo(() => (props.isPhoneLayout() ? "phone" : "nonphone"))
|
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
|
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) => {
|
const persistListOpen = (tab: "changes" | "git-changes" | "files", value: boolean) => {
|
||||||
if (typeof window === "undefined") return
|
if (typeof window === "undefined") return
|
||||||
window.localStorage.setItem(listOpenStorageKey(tab), value ? "true" : "false")
|
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(() => {
|
createEffect(() => {
|
||||||
// Refresh persisted visibility when layout changes (phone vs non-phone).
|
// Refresh persisted visibility when layout changes (phone vs non-phone).
|
||||||
const layout = listLayoutKey()
|
const layout = listLayoutKey()
|
||||||
@@ -185,6 +216,12 @@ const RightPanel: Component<RightPanelProps> = (props) => {
|
|||||||
setGitChangesListOpen(true)
|
setGitChangesListOpen(true)
|
||||||
setGitChangesListTouched(false)
|
setGitChangesListTouched(false)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const stagedPersisted = readStoredBool(gitSectionStorageKey("staged"))
|
||||||
|
setGitStagedOpen(stagedPersisted ?? true)
|
||||||
|
|
||||||
|
const unstagedPersisted = readStoredBool(gitSectionStorageKey("unstaged"))
|
||||||
|
setGitUnstagedOpen(unstagedPersisted ?? true)
|
||||||
})
|
})
|
||||||
|
|
||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
@@ -339,34 +376,56 @@ const RightPanel: Component<RightPanelProps> = (props) => {
|
|||||||
return getDefaultWorktreeSlug(props.instanceId)
|
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 browserClient = createMemo(() => getOrCreateWorktreeClient(props.instanceId, worktreeSlugForViewer()))
|
||||||
|
|
||||||
const [gitStatusEntries, setGitStatusEntries] = createSignal<GitFileStatus[] | null>(null)
|
const {
|
||||||
const [gitStatusLoading, setGitStatusLoading] = createSignal(false)
|
gitStatusEntries,
|
||||||
const [gitStatusError, setGitStatusError] = createSignal<string | null>(null)
|
gitStatusLoading,
|
||||||
const [gitSelectedPath, setGitSelectedPath] = createSignal<string | null>(null)
|
gitStatusError,
|
||||||
const [gitSelectedLoading, setGitSelectedLoading] = createSignal(false)
|
gitSelectedItemId,
|
||||||
const [gitSelectedError, setGitSelectedError] = createSignal<string | null>(null)
|
gitBulkSelectedItemIds,
|
||||||
const [gitSelectedBefore, setGitSelectedBefore] = createSignal<string | null>(null)
|
gitSelectedLoading,
|
||||||
const [gitSelectedAfter, setGitSelectedAfter] = createSignal<string | null>(null)
|
gitSelectedError,
|
||||||
|
gitSelectedBefore,
|
||||||
const gitMostChangedPath = createMemo<string | null>(() => {
|
gitSelectedAfter,
|
||||||
const entries = gitStatusEntries()
|
gitCommitMessage,
|
||||||
if (!Array.isArray(entries) || entries.length === 0) return null
|
gitCommitSubmitting,
|
||||||
const candidates = entries.filter((item) => item && item.status !== "deleted")
|
gitMostChangedItemId,
|
||||||
if (candidates.length === 0) return null
|
setGitCommitMessage,
|
||||||
const best = candidates.reduce((currentBest, item) => {
|
handleGitRowClick,
|
||||||
const bestScore = (currentBest?.added ?? 0) + (currentBest?.removed ?? 0)
|
refreshGitStatus,
|
||||||
const score = (item?.added ?? 0) + (item?.removed ?? 0)
|
insertGitChangeContext,
|
||||||
if (score > bestScore) return item
|
submitGitCommit,
|
||||||
if (score < bestScore) return currentBest
|
stageGitFile,
|
||||||
return String(item.path || "").localeCompare(String(currentBest?.path || "")) < 0 ? item : currentBest
|
unstageGitFile,
|
||||||
}, candidates[0])
|
} = useGitChanges({
|
||||||
return typeof best?.path === "string" ? best.path : null
|
t: props.t,
|
||||||
|
instanceId: props.instanceId,
|
||||||
|
rightPanelTab,
|
||||||
|
worktreeSlug: worktreeSlugForViewer,
|
||||||
|
isPhoneLayout: props.isPhoneLayout,
|
||||||
|
promptInputApi: props.promptInputApi,
|
||||||
|
closeGitList: () => setGitChangesListOpen(false),
|
||||||
})
|
})
|
||||||
|
|
||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
// Reset tab state when worktree context changes.
|
|
||||||
worktreeSlugForViewer()
|
worktreeSlugForViewer()
|
||||||
setBrowserPath(".")
|
setBrowserPath(".")
|
||||||
setBrowserEntries(null)
|
setBrowserEntries(null)
|
||||||
@@ -375,111 +434,8 @@ const RightPanel: Component<RightPanelProps> = (props) => {
|
|||||||
setBrowserSelectedContent(null)
|
setBrowserSelectedContent(null)
|
||||||
setBrowserSelectedError(null)
|
setBrowserSelectedError(null)
|
||||||
setBrowserSelectedLoading(false)
|
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 bestDiffFile = createMemo<string | null>(() => {
|
||||||
const diffs = props.activeSessionDiffs()
|
const diffs = props.activeSessionDiffs()
|
||||||
if (!Array.isArray(diffs) || diffs.length === 0) return null
|
if (!Array.isArray(diffs) || diffs.length === 0) return null
|
||||||
@@ -680,21 +636,6 @@ const RightPanel: Component<RightPanelProps> = (props) => {
|
|||||||
setBrowserSelectedDirty(false)
|
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) => {
|
const handleSelectChangesFile = (file: string, closeList: boolean) => {
|
||||||
setSelectedFile(file)
|
setSelectedFile(file)
|
||||||
if (closeList) {
|
if (closeList) {
|
||||||
@@ -911,12 +852,13 @@ const RightPanel: Component<RightPanelProps> = (props) => {
|
|||||||
entries={gitStatusEntries}
|
entries={gitStatusEntries}
|
||||||
statusLoading={gitStatusLoading}
|
statusLoading={gitStatusLoading}
|
||||||
statusError={gitStatusError}
|
statusError={gitStatusError}
|
||||||
selectedPath={gitSelectedPath}
|
selectedItemId={gitSelectedItemId}
|
||||||
|
selectedBulkItemIds={gitBulkSelectedItemIds}
|
||||||
selectedLoading={gitSelectedLoading}
|
selectedLoading={gitSelectedLoading}
|
||||||
selectedError={gitSelectedError}
|
selectedError={gitSelectedError}
|
||||||
selectedBefore={gitSelectedBefore}
|
selectedBefore={gitSelectedBefore}
|
||||||
selectedAfter={gitSelectedAfter}
|
selectedAfter={gitSelectedAfter}
|
||||||
mostChangedPath={gitMostChangedPath}
|
mostChangedItemId={gitMostChangedItemId}
|
||||||
scopeKey={gitScopeKey}
|
scopeKey={gitScopeKey}
|
||||||
diffViewMode={diffViewMode}
|
diffViewMode={diffViewMode}
|
||||||
diffContextMode={diffContextMode}
|
diffContextMode={diffContextMode}
|
||||||
@@ -924,8 +866,28 @@ const RightPanel: Component<RightPanelProps> = (props) => {
|
|||||||
onViewModeChange={setDiffViewMode}
|
onViewModeChange={setDiffViewMode}
|
||||||
onContextModeChange={setDiffContextMode}
|
onContextModeChange={setDiffContextMode}
|
||||||
onWordWrapModeChange={setDiffWordWrapMode}
|
onWordWrapModeChange={setDiffWordWrapMode}
|
||||||
onOpenFile={(path: string) => void openGitFile(path)}
|
onRowClick={handleGitRowClick}
|
||||||
onRefresh={() => void refreshGitStatus()}
|
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}
|
listOpen={gitChangesListOpen}
|
||||||
onToggleList={toggleGitList}
|
onToggleList={toggleGitList}
|
||||||
splitWidth={gitChangesSplitWidth}
|
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 {
|
||||||
import type { File as GitFileStatus } from "@opencode-ai/sdk/v2/client"
|
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 DiffToolbar from "../components/DiffToolbar"
|
||||||
import SplitFilePanel from "../components/SplitFilePanel"
|
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(() =>
|
const LazyMonacoDiffViewer = lazy(() =>
|
||||||
import("../../../../file-viewer/monaco-diff-viewer").then((module) => ({ default: module.MonacoDiffViewer })),
|
import("../../../../file-viewer/monaco-diff-viewer").then((module) => ({ default: module.MonacoDiffViewer })),
|
||||||
@@ -16,16 +25,17 @@ interface GitChangesTabProps {
|
|||||||
|
|
||||||
activeSessionId: Accessor<string | null>
|
activeSessionId: Accessor<string | null>
|
||||||
|
|
||||||
entries: Accessor<GitFileStatus[] | null>
|
entries: Accessor<GitChangeEntry[] | null>
|
||||||
statusLoading: Accessor<boolean>
|
statusLoading: Accessor<boolean>
|
||||||
statusError: Accessor<string | null>
|
statusError: Accessor<string | null>
|
||||||
|
|
||||||
selectedPath: Accessor<string | null>
|
selectedItemId: Accessor<string | null>
|
||||||
|
selectedBulkItemIds: Accessor<Set<string>>
|
||||||
selectedLoading: Accessor<boolean>
|
selectedLoading: Accessor<boolean>
|
||||||
selectedError: Accessor<string | null>
|
selectedError: Accessor<string | null>
|
||||||
selectedBefore: Accessor<string | null>
|
selectedBefore: Accessor<string | null>
|
||||||
selectedAfter: Accessor<string | null>
|
selectedAfter: Accessor<string | null>
|
||||||
mostChangedPath: Accessor<string | null>
|
mostChangedItemId: Accessor<string | null>
|
||||||
|
|
||||||
scopeKey: Accessor<string>
|
scopeKey: Accessor<string>
|
||||||
|
|
||||||
@@ -36,8 +46,21 @@ interface GitChangesTabProps {
|
|||||||
onContextModeChange: (mode: DiffContextMode) => void
|
onContextModeChange: (mode: DiffContextMode) => void
|
||||||
onWordWrapModeChange: (mode: DiffWordWrapMode) => void
|
onWordWrapModeChange: (mode: DiffWordWrapMode) => void
|
||||||
|
|
||||||
onOpenFile: (path: string) => void
|
onRowClick: (item: GitChangeListItem, event: MouseEvent) => void
|
||||||
onRefresh: () => 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>
|
listOpen: Accessor<boolean>
|
||||||
onToggleList: () => void
|
onToggleList: () => void
|
||||||
@@ -52,48 +75,54 @@ const GitChangesTab: Component<GitChangesTabProps> = (props) => {
|
|||||||
const hasSession = createMemo(() => Boolean(sessionId() && sessionId() !== "info"))
|
const hasSession = createMemo(() => Boolean(sessionId() && sessionId() !== "info"))
|
||||||
const entries = createMemo(() => (hasSession() ? props.entries() : null))
|
const entries = createMemo(() => (hasSession() ? props.entries() : null))
|
||||||
|
|
||||||
const sorted = createMemo<GitFileStatus[]>(() => {
|
const sorted = createMemo<GitChangeEntry[]>(() => {
|
||||||
const list = entries()
|
const list = entries()
|
||||||
if (!Array.isArray(list)) return []
|
if (!Array.isArray(list)) return []
|
||||||
return [...list].sort((a, b) => String(a.path || "").localeCompare(String(b.path || "")))
|
return [...list].sort((a, b) => String(a.path || "").localeCompare(String(b.path || "")))
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const listItems = createMemo<GitChangeListItem[]>(() => buildGitChangeListItems(sorted()))
|
||||||
|
|
||||||
const totals = createMemo(() => {
|
const totals = createMemo(() => {
|
||||||
return sorted().reduce(
|
return listItems().reduce(
|
||||||
(acc, item) => {
|
(acc, item) => {
|
||||||
acc.additions += typeof item.added === "number" ? item.added : 0
|
acc.additions += typeof item.additions === "number" ? item.additions : 0
|
||||||
acc.deletions += typeof item.removed === "number" ? item.removed : 0
|
acc.deletions += typeof item.deletions === "number" ? item.deletions : 0
|
||||||
return acc
|
return acc
|
||||||
},
|
},
|
||||||
{ additions: 0, deletions: 0 },
|
{ 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<GitChangeEntry | null>(() => {
|
||||||
|
const list = listItems()
|
||||||
const selectedEntry = createMemo<GitFileStatus | null>(() => {
|
const selectedId = props.selectedItemId()
|
||||||
const list = sorted()
|
const fallbackId = props.mostChangedItemId()
|
||||||
const selectedPath = props.selectedPath()
|
|
||||||
const fallbackPath = props.mostChangedPath()
|
|
||||||
const found =
|
const found =
|
||||||
list.find((item) => item.path === selectedPath) ||
|
list.find((item) => item.id === selectedId) ||
|
||||||
(fallbackPath ? list.find((item) => item.path === fallbackPath) : undefined)
|
(fallbackId ? list.find((item) => item.id === fallbackId) : undefined)
|
||||||
return found ?? null
|
return found?.entry ?? null
|
||||||
})
|
})
|
||||||
|
|
||||||
const emptyViewerMessage = createMemo(() => {
|
const emptyViewerMessage = createMemo(() => {
|
||||||
if (!hasSession()) return props.t("instanceShell.gitChanges.noSessionSelected")
|
if (!hasSession()) return props.t("instanceShell.gitChanges.noSessionSelected")
|
||||||
const currentEntries = entries()
|
const currentEntries = entries()
|
||||||
if (currentEntries === null) return props.t("instanceShell.gitChanges.loading")
|
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")
|
return props.t("instanceShell.filesShell.viewerEmpty")
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const binaryViewerActive = createMemo(() => props.selectedError() === props.t("instanceShell.gitChanges.binaryViewer"))
|
||||||
|
|
||||||
const renderContent = (): JSX.Element => {
|
const renderContent = (): JSX.Element => {
|
||||||
const totalsValue = totals()
|
const totalsValue = totals()
|
||||||
const selected = selectedEntry()
|
const selected = selectedEntry()
|
||||||
const sortedList = sorted()
|
const allItems = listItems()
|
||||||
const nonDeletedList = nonDeleted()
|
const stagedList = stagedItems()
|
||||||
|
const unstagedList = unstagedItems()
|
||||||
|
|
||||||
const renderViewer = () => (
|
const renderViewer = () => (
|
||||||
<div class="file-viewer-panel flex-1">
|
<div class="file-viewer-panel flex-1">
|
||||||
@@ -109,7 +138,7 @@ const GitChangesTab: Component<GitChangesTabProps> = (props) => {
|
|||||||
selected &&
|
selected &&
|
||||||
props.selectedBefore() !== null &&
|
props.selectedBefore() !== null &&
|
||||||
props.selectedAfter() !== null &&
|
props.selectedAfter() !== null &&
|
||||||
selected.status !== "deleted"
|
true
|
||||||
? {
|
? {
|
||||||
path: selected.path,
|
path: selected.path,
|
||||||
before: props.selectedBefore() as string,
|
before: props.selectedBefore() as string,
|
||||||
@@ -139,6 +168,14 @@ const GitChangesTab: Component<GitChangesTabProps> = (props) => {
|
|||||||
viewMode={props.diffViewMode()}
|
viewMode={props.diffViewMode()}
|
||||||
contextMode={props.diffContextMode()}
|
contextMode={props.diffContextMode()}
|
||||||
wordWrap={props.diffWordWrapMode()}
|
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>
|
</Suspense>
|
||||||
)}
|
)}
|
||||||
@@ -163,66 +200,149 @@ const GitChangesTab: Component<GitChangesTabProps> = (props) => {
|
|||||||
|
|
||||||
const renderEmptyList = () => <div class="p-3 text-xs text-secondary">{emptyViewerMessage()}</div>
|
const renderEmptyList = () => <div class="p-3 text-xs text-secondary">{emptyViewerMessage()}</div>
|
||||||
|
|
||||||
const renderListPanel = () => (
|
const renderListItem = (item: GitChangeListItem) => {
|
||||||
<Show when={nonDeletedList.length > 0} fallback={renderEmptyList()}>
|
const isBulkSelected = createMemo(() => props.selectedBulkItemIds().has(item.id))
|
||||||
<For each={sortedList}>
|
const actionLabel =
|
||||||
{(item) => (
|
item.section === "staged"
|
||||||
<div
|
? props.t("instanceShell.gitChanges.actions.unstage")
|
||||||
class={`file-list-item ${props.selectedPath() === item.path ? "file-list-item-active" : ""}`}
|
: props.t("instanceShell.gitChanges.actions.stage")
|
||||||
onClick={() => {
|
|
||||||
props.onOpenFile(item.path)
|
const triggerAction = () => {
|
||||||
}}
|
if (item.section === "staged") props.onUnstageFile(item)
|
||||||
>
|
else props.onStageFile(item)
|
||||||
<div class="file-list-item-content">
|
}
|
||||||
<div class="file-list-item-path" title={item.path}>
|
|
||||||
<span class="file-path-text">{item.path}</span>
|
return (
|
||||||
</div>
|
<div
|
||||||
<div class="file-list-item-stats">
|
class={`file-list-item git-change-list-item ${props.selectedItemId() === item.id ? "file-list-item-active" : ""} ${isBulkSelected() ? "git-change-list-item-bulk-selected" : ""}`}
|
||||||
<Show when={item.status === "deleted"}>
|
onMouseDown={(event) => {
|
||||||
<span class="text-[10px] text-secondary">{props.t("instanceShell.gitChanges.deleted")}</span>
|
if (event.shiftKey || event.ctrlKey || event.metaKey) {
|
||||||
</Show>
|
event.preventDefault()
|
||||||
<Show when={item.status !== "deleted"}>
|
}
|
||||||
<>
|
}}
|
||||||
<span class="file-list-item-additions">+{item.added}</span>
|
onClick={(event) => props.onRowClick(item, event)}
|
||||||
<span class="file-list-item-deletions">-{item.removed}</span>
|
title={item.path}
|
||||||
</>
|
>
|
||||||
</Show>
|
<div class="file-list-item-content" title={item.path}>
|
||||||
</div>
|
<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>
|
||||||
</div>
|
</div>
|
||||||
)}
|
</div>
|
||||||
</For>
|
<div class="git-change-list-item-actions-zone">
|
||||||
</Show>
|
<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 = () => (
|
const renderGroupedList = () => (
|
||||||
<Show when={nonDeletedList.length > 0} fallback={renderEmptyList()}>
|
<Show when={allItems.length > 0} fallback={renderEmptyList()}>
|
||||||
<For each={sortedList}>
|
<div class="git-change-sections">
|
||||||
{(item) => (
|
<div class="git-change-section">
|
||||||
<div
|
<button type="button" class="git-change-section-header" onClick={props.onToggleStagedOpen}>
|
||||||
class={`file-list-item ${props.selectedPath() === item.path ? "file-list-item-active" : ""}`}
|
<span class="git-change-section-header-main">
|
||||||
onClick={() => props.onOpenFile(item.path)}
|
<span class="git-change-section-chevron">
|
||||||
title={item.path}
|
{props.stagedOpen() ? <ChevronDown class="h-3.5 w-3.5" /> : <ChevronRight class="h-3.5 w-3.5" />}
|
||||||
>
|
</span>
|
||||||
<div class="file-list-item-content">
|
<span class="git-change-section-title-row">
|
||||||
<div class="file-list-item-path" title={item.path}>
|
<span class="git-change-section-title">{props.t("instanceShell.gitChanges.sections.staged")}</span>
|
||||||
<span class="file-path-text">{item.path}</span>
|
<Show when={props.branchLabel()}>
|
||||||
</div>
|
{(label) => (
|
||||||
<div class="file-list-item-stats">
|
<span class="status-indicator session-status-list worktree-indicator git-change-section-badge" title={`Branch: ${label()}`}>
|
||||||
<Show when={item.status === "deleted"}>
|
<GitBranch class="w-3.5 h-3.5" aria-hidden="true" />
|
||||||
<span class="text-[10px] text-secondary">{props.t("instanceShell.gitChanges.deleted")}</span>
|
<span class="worktree-indicator-label">{label()}</span>
|
||||||
</Show>
|
</span>
|
||||||
<Show when={item.status !== "deleted"}>
|
)}
|
||||||
<>
|
|
||||||
<span class="file-list-item-additions">+{item.added}</span>
|
|
||||||
<span class="file-list-item-deletions">-{item.removed}</span>
|
|
||||||
</>
|
|
||||||
</Show>
|
</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>
|
</div>
|
||||||
|
<For each={stagedList}>{(item) => renderListItem(item)}</For>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</Show>
|
||||||
|
</div>
|
||||||
|
{renderSection(
|
||||||
|
props.t("instanceShell.gitChanges.sections.unstaged"),
|
||||||
|
unstagedList,
|
||||||
|
props.unstagedOpen(),
|
||||||
|
props.onToggleUnstagedOpen,
|
||||||
)}
|
)}
|
||||||
</For>
|
</div>
|
||||||
</Show>
|
</Show>
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -266,7 +386,7 @@ const GitChangesTab: Component<GitChangesTabProps> = (props) => {
|
|||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
}
|
}
|
||||||
list={{ panel: renderListPanel, overlay: renderListOverlay }}
|
list={{ panel: renderGroupedList, overlay: renderGroupedList }}
|
||||||
viewer={renderViewer()}
|
viewer={renderViewer()}
|
||||||
listOpen={props.listOpen()}
|
listOpen={props.listOpen()}
|
||||||
onToggleList={props.onToggleList}
|
onToggleList={props.onToggleList}
|
||||||
|
|||||||
@@ -5,3 +5,40 @@ export type DiffViewMode = "split" | "unified"
|
|||||||
export type DiffContextMode = "expanded" | "collapsed"
|
export type DiffContextMode = "expanded" | "collapsed"
|
||||||
|
|
||||||
export type DiffWordWrapMode = "on" | "off"
|
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_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_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_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_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_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"
|
export const RIGHT_PANEL_CHANGES_DIFF_WORD_WRAP_KEY = "opencode-session-right-panel-changes-diff-word-wrap-v1"
|
||||||
|
|||||||
@@ -120,6 +120,11 @@ export default function PromptInput(props: PromptInputProps) {
|
|||||||
insertQuotedSelection(text)
|
insertQuotedSelection(text)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
insertComment: (text: string) => {
|
||||||
|
const normalized = (text ?? "").replace(/\r/g, "").trim()
|
||||||
|
if (!normalized) return
|
||||||
|
insertBlockContent(`${normalized}\n\n`)
|
||||||
|
},
|
||||||
expandTextAttachment: (attachmentId: string) => {
|
expandTextAttachment: (attachmentId: string) => {
|
||||||
const attachment = attachments().find((a) => a.id === attachmentId)
|
const attachment = attachments().find((a) => a.id === attachmentId)
|
||||||
if (!attachment) return
|
if (!attachment) return
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ export type PromptInsertMode = "quote" | "code"
|
|||||||
|
|
||||||
export interface PromptInputApi {
|
export interface PromptInputApi {
|
||||||
insertSelection(text: string, mode: PromptInsertMode): void
|
insertSelection(text: string, mode: PromptInsertMode): void
|
||||||
|
insertComment(text: string): void
|
||||||
expandTextAttachment(attachmentId: string): void
|
expandTextAttachment(attachmentId: string): void
|
||||||
removeAttachment(attachmentId: string): void
|
removeAttachment(attachmentId: string): void
|
||||||
setPromptText(text: string, opts?: { focus?: boolean }): void
|
setPromptText(text: string, opts?: { focus?: boolean }): void
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ interface SessionViewProps {
|
|||||||
onSidebarToggle?: () => void
|
onSidebarToggle?: () => void
|
||||||
forceCompactStatusLayout?: boolean
|
forceCompactStatusLayout?: boolean
|
||||||
isActive?: boolean
|
isActive?: boolean
|
||||||
|
registerSessionPromptApi?: (sessionId: string, api: PromptInputApi | null) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
export const SessionView: Component<SessionViewProps> = (props) => {
|
export const SessionView: Component<SessionViewProps> = (props) => {
|
||||||
@@ -149,6 +150,7 @@ export const SessionView: Component<SessionViewProps> = (props) => {
|
|||||||
|
|
||||||
function registerPromptInputApi(api: PromptInputApi) {
|
function registerPromptInputApi(api: PromptInputApi) {
|
||||||
promptInputApi = api
|
promptInputApi = api
|
||||||
|
props.registerSessionPromptApi?.(props.sessionId, api)
|
||||||
|
|
||||||
if (pendingPromptText) {
|
if (pendingPromptText) {
|
||||||
api.setPromptText(pendingPromptText, { focus: true })
|
api.setPromptText(pendingPromptText, { focus: true })
|
||||||
@@ -163,6 +165,7 @@ export const SessionView: Component<SessionViewProps> = (props) => {
|
|||||||
return () => {
|
return () => {
|
||||||
if (promptInputApi === api) {
|
if (promptInputApi === api) {
|
||||||
promptInputApi = null
|
promptInputApi = null
|
||||||
|
props.registerSessionPromptApi?.(props.sessionId, null)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -174,6 +174,8 @@ export default function VirtualFollowList<T>(props: VirtualFollowListProps<T>) {
|
|||||||
let lastResetKey: string | number | undefined
|
let lastResetKey: string | number | undefined
|
||||||
let suppressAutoScrollOnce = false
|
let suppressAutoScrollOnce = false
|
||||||
let pendingInitialScroll = true
|
let pendingInitialScroll = true
|
||||||
|
let lastObservedScrollOffset = 0
|
||||||
|
let lastObservedPinnedAtBottom = false
|
||||||
|
|
||||||
const state: VirtualFollowListState = {
|
const state: VirtualFollowListState = {
|
||||||
autoScroll,
|
autoScroll,
|
||||||
@@ -239,15 +241,29 @@ export default function VirtualFollowList<T>(props: VirtualFollowListProps<T>) {
|
|||||||
if (!handle || !element) return
|
if (!handle || !element) return
|
||||||
|
|
||||||
const offset = handle.scrollOffset
|
const offset = handle.scrollOffset
|
||||||
|
const scrolledUp = offset < lastObservedScrollOffset - 1
|
||||||
|
const wasPinnedAtBottom = lastObservedPinnedAtBottom
|
||||||
const scrollHeight = handle.scrollSize
|
const scrollHeight = handle.scrollSize
|
||||||
const clientHeight = element.clientHeight
|
const clientHeight = element.clientHeight
|
||||||
const atBottom = scrollHeight - (offset + clientHeight) <= (props.scrollSentinelMarginPx ?? DEFAULT_SCROLL_SENTINEL_MARGIN_PX)
|
const atBottom = scrollHeight - (offset + clientHeight) <= (props.scrollSentinelMarginPx ?? DEFAULT_SCROLL_SENTINEL_MARGIN_PX)
|
||||||
const atTop = offset <= (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
|
const hasItems = props.items().length > 0
|
||||||
setShowScrollBottomButton(hasItems && !atBottom)
|
setShowScrollBottomButton(hasItems && !atBottom)
|
||||||
setShowScrollTopButton(hasItems && !atTop)
|
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
|
// Sync autoScroll state based on scroll position if it was a user scroll
|
||||||
if (hasUserScrollIntent()) {
|
if (hasUserScrollIntent()) {
|
||||||
if (atBottom && heldItemCount() !== null) {
|
if (atBottom && heldItemCount() !== null) {
|
||||||
@@ -259,6 +275,8 @@ export default function VirtualFollowList<T>(props: VirtualFollowListProps<T>) {
|
|||||||
setAutoScroll(false)
|
setAutoScroll(false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
lastObservedPinnedAtBottom = autoScroll() && atBottom
|
||||||
}
|
}
|
||||||
|
|
||||||
function scrollToBottom(immediate = true, options?: { suppressAutoAnchor?: boolean }) {
|
function scrollToBottom(immediate = true, options?: { suppressAutoAnchor?: boolean }) {
|
||||||
@@ -395,6 +413,8 @@ export default function VirtualFollowList<T>(props: VirtualFollowListProps<T>) {
|
|||||||
createEffect(on(() => props.resetKey?.(), () => {
|
createEffect(on(() => props.resetKey?.(), () => {
|
||||||
itemElements.clear()
|
itemElements.clear()
|
||||||
setHeldItemCount(null)
|
setHeldItemCount(null)
|
||||||
|
lastObservedScrollOffset = 0
|
||||||
|
lastObservedPinnedAtBottom = false
|
||||||
}))
|
}))
|
||||||
|
|
||||||
// Handle autoScroll (Follow) on items change
|
// Handle autoScroll (Follow) on items change
|
||||||
|
|||||||
@@ -12,9 +12,16 @@ import type {
|
|||||||
SpeechTranscriptionResponse,
|
SpeechTranscriptionResponse,
|
||||||
SideCar,
|
SideCar,
|
||||||
ServerMeta,
|
ServerMeta,
|
||||||
|
RemoteProxySessionCreateRequest,
|
||||||
|
RemoteProxySessionCreateResponse,
|
||||||
RemoteServerProbeRequest,
|
RemoteServerProbeRequest,
|
||||||
RemoteServerProbeResponse,
|
RemoteServerProbeResponse,
|
||||||
VoiceModeStateResponse,
|
VoiceModeStateResponse,
|
||||||
|
WorktreeGitCommitRequest,
|
||||||
|
WorktreeGitCommitResponse,
|
||||||
|
WorktreeGitDiffRequest,
|
||||||
|
WorktreeGitMutationResponse,
|
||||||
|
WorktreeGitPathsRequest,
|
||||||
WorkspaceCreateRequest,
|
WorkspaceCreateRequest,
|
||||||
WorkspaceDescriptor,
|
WorkspaceDescriptor,
|
||||||
WorkspaceFileResponse,
|
WorkspaceFileResponse,
|
||||||
@@ -26,6 +33,8 @@ import type {
|
|||||||
WorktreeListResponse,
|
WorktreeListResponse,
|
||||||
WorktreeMap,
|
WorktreeMap,
|
||||||
WorktreeCreateRequest,
|
WorktreeCreateRequest,
|
||||||
|
WorktreeGitDiffResponse,
|
||||||
|
WorktreeGitStatusResponse,
|
||||||
} from "../../../server/src/api-types"
|
} from "../../../server/src/api-types"
|
||||||
import { getClientIdentity } from "./client-identity"
|
import { getClientIdentity } from "./client-identity"
|
||||||
import { getLogger } from "./logger"
|
import { getLogger } from "./logger"
|
||||||
@@ -98,6 +107,25 @@ function logHttp(message: string, context?: Record<string, unknown>) {
|
|||||||
httpLogger.info(message)
|
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> {
|
async function request<T>(path: string, init?: RequestInit): Promise<T> {
|
||||||
const url = API_BASE ? new URL(path, API_BASE).toString() : path
|
const url = API_BASE ? new URL(path, API_BASE).toString() : path
|
||||||
const headers = normalizeHeaders(init?.headers)
|
const headers = normalizeHeaders(init?.headers)
|
||||||
@@ -112,7 +140,7 @@ async function request<T>(path: string, init?: RequestInit): Promise<T> {
|
|||||||
try {
|
try {
|
||||||
const response = await fetch(url, { ...init, headers, credentials: init?.credentials ?? "include" })
|
const response = await fetch(url, { ...init, headers, credentials: init?.credentials ?? "include" })
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
const message = await response.text()
|
const message = await readErrorMessage(response)
|
||||||
logHttp(`${method} ${path} -> ${response.status}`, { durationMs: Date.now() - startedAt, error: message })
|
logHttp(`${method} ${path} -> ${response.status}`, { durationMs: Date.now() - startedAt, error: message })
|
||||||
throw new Error(message || `Request failed with ${response.status}`)
|
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" })
|
const response = await fetch(url, { ...init, headers, credentials: init?.credentials ?? "include" })
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
const message = await response.text()
|
const message = await readErrorMessage(response)
|
||||||
logHttp(`${method} ${path} -> ${response.status}`, { durationMs: Date.now() - startedAt, error: message })
|
logHttp(`${method} ${path} -> ${response.status}`, { durationMs: Date.now() - startedAt, error: message })
|
||||||
throw new Error(message || `Request failed with ${response.status}`)
|
throw new Error(message || `Request failed with ${response.status}`)
|
||||||
}
|
}
|
||||||
@@ -230,6 +258,12 @@ export const serverApi = {
|
|||||||
body: JSON.stringify(payload),
|
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 }> {
|
fetchAuthStatus(): Promise<{ authenticated: boolean; username?: string; passwordUserProvided?: boolean }> {
|
||||||
return request<{ authenticated: boolean; username?: string; passwordUserProvided?: boolean }>("/api/auth/status")
|
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> {
|
fetchConfigOwner<T extends Record<string, any> = Record<string, any>>(owner: string): Promise<T> {
|
||||||
return request<T>(`/api/storage/config/${encodeURIComponent(owner)}`)
|
return request<T>(`/api/storage/config/${encodeURIComponent(owner)}`)
|
||||||
|
|||||||
@@ -131,6 +131,17 @@ export const instanceMessages = {
|
|||||||
"instanceShell.gitChanges.loading": "Loading git changes...",
|
"instanceShell.gitChanges.loading": "Loading git changes...",
|
||||||
"instanceShell.gitChanges.empty": "No git changes yet.",
|
"instanceShell.gitChanges.empty": "No git changes yet.",
|
||||||
"instanceShell.gitChanges.deleted": "Deleted",
|
"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.fileListTitle": "File list",
|
||||||
"instanceShell.filesShell.mobileSelectorLabel": "Select file",
|
"instanceShell.filesShell.mobileSelectorLabel": "Select file",
|
||||||
|
|||||||
@@ -130,6 +130,17 @@ export const instanceMessages = {
|
|||||||
"instanceShell.gitChanges.loading": "Cargando cambios de Git...",
|
"instanceShell.gitChanges.loading": "Cargando cambios de Git...",
|
||||||
"instanceShell.gitChanges.empty": "Aún no hay cambios de Git.",
|
"instanceShell.gitChanges.empty": "Aún no hay cambios de Git.",
|
||||||
"instanceShell.gitChanges.deleted": "Eliminado",
|
"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.fileListTitle": "Lista de archivos",
|
||||||
"instanceShell.filesShell.mobileSelectorLabel": "Seleccionar archivo",
|
"instanceShell.filesShell.mobileSelectorLabel": "Seleccionar archivo",
|
||||||
|
|||||||
@@ -130,6 +130,17 @@ export const instanceMessages = {
|
|||||||
"instanceShell.gitChanges.loading": "Chargement des changements Git...",
|
"instanceShell.gitChanges.loading": "Chargement des changements Git...",
|
||||||
"instanceShell.gitChanges.empty": "Aucun changement Git pour l'instant.",
|
"instanceShell.gitChanges.empty": "Aucun changement Git pour l'instant.",
|
||||||
"instanceShell.gitChanges.deleted": "Supprimé",
|
"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.fileListTitle": "Liste des fichiers",
|
||||||
"instanceShell.filesShell.mobileSelectorLabel": "Sélectionner un fichier",
|
"instanceShell.filesShell.mobileSelectorLabel": "Sélectionner un fichier",
|
||||||
|
|||||||
@@ -138,6 +138,17 @@ export const instanceMessages = {
|
|||||||
"instanceShell.gitChanges.noSessionSelected": "בחר סשן לצפייה בשינויי Git.",
|
"instanceShell.gitChanges.noSessionSelected": "בחר סשן לצפייה בשינויי Git.",
|
||||||
"instanceShell.gitChanges.loading": "טוען שינויי Git…",
|
"instanceShell.gitChanges.loading": "טוען שינויי Git…",
|
||||||
"instanceShell.gitChanges.empty": "אין שינויי 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.hideUnchanged": "הסתר אזורים ללא שינוי",
|
||||||
"instanceShell.diff.showFull": "הצג קובץ מלא",
|
"instanceShell.diff.showFull": "הצג קובץ מלא",
|
||||||
"instanceShell.diff.switchToSplit": "עבור לתצוגה מפוצלת",
|
"instanceShell.diff.switchToSplit": "עבור לתצוגה מפוצלת",
|
||||||
|
|||||||
@@ -130,6 +130,17 @@ export const instanceMessages = {
|
|||||||
"instanceShell.gitChanges.loading": "Git の変更を読み込み中...",
|
"instanceShell.gitChanges.loading": "Git の変更を読み込み中...",
|
||||||
"instanceShell.gitChanges.empty": "Git の変更はまだありません。",
|
"instanceShell.gitChanges.empty": "Git の変更はまだありません。",
|
||||||
"instanceShell.gitChanges.deleted": "削除済み",
|
"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.fileListTitle": "ファイル一覧",
|
||||||
"instanceShell.filesShell.mobileSelectorLabel": "ファイルを選択",
|
"instanceShell.filesShell.mobileSelectorLabel": "ファイルを選択",
|
||||||
|
|||||||
@@ -130,6 +130,17 @@ export const instanceMessages = {
|
|||||||
"instanceShell.gitChanges.loading": "Загрузка изменений Git...",
|
"instanceShell.gitChanges.loading": "Загрузка изменений Git...",
|
||||||
"instanceShell.gitChanges.empty": "Изменений Git пока нет.",
|
"instanceShell.gitChanges.empty": "Изменений Git пока нет.",
|
||||||
"instanceShell.gitChanges.deleted": "Удалено",
|
"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.fileListTitle": "Список файлов",
|
||||||
"instanceShell.filesShell.mobileSelectorLabel": "Выбрать файл",
|
"instanceShell.filesShell.mobileSelectorLabel": "Выбрать файл",
|
||||||
|
|||||||
@@ -130,6 +130,17 @@ export const instanceMessages = {
|
|||||||
"instanceShell.gitChanges.loading": "正在加载 Git 更改...",
|
"instanceShell.gitChanges.loading": "正在加载 Git 更改...",
|
||||||
"instanceShell.gitChanges.empty": "暂无 Git 更改。",
|
"instanceShell.gitChanges.empty": "暂无 Git 更改。",
|
||||||
"instanceShell.gitChanges.deleted": "已删除",
|
"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.fileListTitle": "文件列表",
|
||||||
"instanceShell.filesShell.mobileSelectorLabel": "选择文件",
|
"instanceShell.filesShell.mobileSelectorLabel": "选择文件",
|
||||||
|
|||||||
@@ -6,14 +6,19 @@ export interface RemoteWindowOpenPayload {
|
|||||||
id: string
|
id: string
|
||||||
name: string
|
name: string
|
||||||
baseUrl: string
|
baseUrl: string
|
||||||
|
entryUrl?: string
|
||||||
skipTlsVerify: boolean
|
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 = {
|
const payload: RemoteWindowOpenPayload = {
|
||||||
id: profile.id,
|
id: profile.id,
|
||||||
name: profile.name,
|
name: profile.name,
|
||||||
baseUrl: profile.baseUrl,
|
baseUrl: profile.baseUrl,
|
||||||
|
entryUrl,
|
||||||
skipTlsVerify: profile.skipTlsVerify,
|
skipTlsVerify: profile.skipTlsVerify,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -220,7 +220,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.file-list-item {
|
.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);
|
border-color: var(--border-base);
|
||||||
background-color: transparent;
|
background-color: transparent;
|
||||||
}
|
}
|
||||||
@@ -234,14 +234,280 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.file-list-item-active {
|
.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);
|
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 {
|
.file-list-item-content {
|
||||||
@apply flex items-center justify-between gap-3;
|
@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 {
|
.file-list-item-path {
|
||||||
@apply text-xs font-mono min-w-0 flex-1 overflow-hidden whitespace-nowrap;
|
@apply text-xs font-mono min-w-0 flex-1 overflow-hidden whitespace-nowrap;
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
@@ -335,6 +601,14 @@
|
|||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
direction: ltr;
|
direction: ltr;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.git-change-context-overlay {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
z-index: 30;
|
||||||
}
|
}
|
||||||
|
|
||||||
.file-viewer-empty {
|
.file-viewer-empty {
|
||||||
@@ -507,6 +781,7 @@
|
|||||||
opacity: 0;
|
opacity: 0;
|
||||||
transform: scale(0.95);
|
transform: scale(0.95);
|
||||||
}
|
}
|
||||||
|
|
||||||
to {
|
to {
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
transform: scale(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
|
id: string
|
||||||
name: string
|
name: string
|
||||||
baseUrl: string
|
baseUrl: string
|
||||||
|
entryUrl?: string
|
||||||
skipTlsVerify: boolean
|
skipTlsVerify: boolean
|
||||||
}) => Promise<{ ok: boolean }>
|
}) => Promise<{ ok: boolean }>
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user