feat: add machine-scoped local API aliases

This commit is contained in:
Marc Kassubeck
2026-05-26 13:06:56 +02:00
parent 418216b9b7
commit b5f8810eda
27 changed files with 664 additions and 229 deletions
+5
View File
@@ -0,0 +1,5 @@
---
"@jmfederico/pi-web": patch
---
Add machine-scoped local project, workspace, file, and git API aliases as the next step toward machine federation.
+286 -26
View File
@@ -654,56 +654,316 @@ Acceptance:
### Phase 2: Machine-scoped local aliases
Deliverable: machine-scoped APIs work for `local`.
Deliverable: machine-scoped APIs work for the synthesized `local` machine, and the browser uses those endpoints for normal local operation. Remote machine rows may still be listed, but remote project/session control remains unavailable until Phase 3/4.
Tasks:
Implementation strategy:
- Add `/api/machines/local/projects` etc. wrappers for local services.
- Add `/api/machines/local/sessions...` proxy wrappers to local sessiond.
- Add `/api/machines/local/events` WebSocket wrappers.
- Update client API/controllers to use machine-scoped endpoints.
- Keep compatibility aliases.
- Prefer extracting existing route registration functions to accept a path prefix when this is low-risk.
- If extraction would be broad, add small wrapper route modules first and refactor later.
- Keep every existing non-machine-scoped route as a compatibility alias for `local`.
- Migrate client calls in route-family slices so regressions are easy to isolate:
1. projects, project directories, workspaces;
2. files, file previews, git;
3. sessions, auth providers, activity HTTP;
4. local WebSockets and terminals if they are not deferred to Phase 4.
Local service route mapping:
```text
GET /api/machines/local/projects
-> ProjectService.list()
POST /api/machines/local/projects
-> ProjectService.add()
DELETE /api/machines/local/projects/:projectId
-> ProjectService.close()
GET /api/machines/local/project-directories?q=...
-> listDirectorySuggestions()
GET /api/machines/local/projects/:projectId/workspaces
-> WorkspaceService.list(project)
GET /api/machines/local/projects/:projectId/workspaces/:workspaceId/tree?path=...
-> listWorkspaceTree()
GET /api/machines/local/projects/:projectId/workspaces/:workspaceId/file?path=...
-> readWorkspaceFile()
GET /api/machines/local/projects/:projectId/workspaces/:workspaceId/file/preview?path=...
-> readWorkspaceImagePreview() streaming response
GET /api/machines/local/projects/:projectId/workspaces/:workspaceId/git/status
-> current git status route behavior
GET /api/machines/local/projects/:projectId/workspaces/:workspaceId/git/diff?path=...&staged=true
-> current git diff route behavior
GET /api/machines/local/files?cwd=...&q=...&kind=...&mode=...
-> listFileSuggestions() / listPathSuggestions()
```
Local session daemon route mapping:
```text
GET/POST/etc /api/machines/local/activity
-> local sessiond /activity
GET/POST/etc /api/machines/local/auth
-> local sessiond /auth
GET/POST/etc /api/machines/local/auth/*
-> local sessiond /auth/*
GET/POST/etc /api/machines/local/sessions
-> local sessiond /sessions
GET/POST/etc /api/machines/local/sessions/*
-> local sessiond /sessions/*
```
Local WebSocket mapping:
```text
WS /api/machines/local/events
-> local sessiond /events
WS /api/machines/local/sessions/events
-> local sessiond /sessions/events
WS /api/machines/local/sessions/:sessionId/events
-> local sessiond /sessions/:sessionId/events
WS /api/machines/local/projects/:projectId/workspaces/:workspaceId/terminals/:terminalId/socket?cols=...&rows=...
-> existing local terminal socket behavior with query preserved
```
Client API changes:
```ts
const machinePrefix = (machineId: string) => `/api/machines/${encodeURIComponent(machineId)}`;
projects(machineId)
addProject(machineId, path, name, create)
closeProject(machineId, projectId)
projectDirectories(machineId, query)
workspaces(machineId, projectId)
workspaceTree(machineId, projectId, workspaceId, path)
workspaceFile(machineId, projectId, workspaceId, path)
workspaceFilePreview(machineId, projectId, workspaceId, path)
gitStatus(machineId, projectId, workspaceId)
gitDiff(machineId, projectId, workspaceId, options)
files(machineId, cwd, query, kind, mode)
sessions(machineId, cwd)
...
```
Controller rules:
- Controllers must derive `machineId` from `selectedMachine?.id ?? "local"`.
- While only local aliases are implemented, remote machines should remain non-operational in the project/session controllers and show clear “remote control coming soon” copy.
- Route restoration must restore machine selection before project/workspace/session selection.
- Cache keys introduced in this phase should be machine-scoped if they can outlive the selected-machine view.
Acceptance:
- Browser uses `/api/machines/local/...` for normal operation.
- Compatibility aliases still pass tests.
- Browser network panel shows `/api/machines/local/...` for local project/workspace/session activity.
- Current compatibility routes such as `/api/projects` and `/api/sessions` still pass tests.
- Existing URLs without `machine` continue to restore local projects/workspaces/sessions.
- `?machine=local` is accepted but normal URL writes omit it.
- Selecting any remote row does not show local projects or local sessions under the remote machine.
### Phase 3: Remote HTTP proxy
Deliverable: remote machines can list projects/workspaces/sessions and perform non-WebSocket actions.
Deliverable: remote machines can list projects/workspaces/sessions and perform non-WebSocket actions through the local Pi Web gateway.
Tasks:
Remote proxy route allowlist:
- Add remote `MachineClient`.
- Add `GET /api/machines/:id/health`.
- Proxy machine-scoped HTTP routes for remote machines to remote compatibility routes.
- Add token/header support for gateway-to-remote authentication.
- Keep OAuth provider login/logout flows remote-direct unless callback origin behavior is explicitly implemented.
- Add UI for add/remove remote machines.
```text
GET /api/machines/:id/projects
POST /api/machines/:id/projects
DELETE /api/machines/:id/projects/:projectId
GET /api/machines/:id/project-directories?q=...
GET /api/machines/:id/projects/:projectId/workspaces
GET /api/machines/:id/projects/:projectId/workspaces/:workspaceId/tree?path=...
GET /api/machines/:id/projects/:projectId/workspaces/:workspaceId/file?path=...
GET /api/machines/:id/projects/:projectId/workspaces/:workspaceId/file/preview?path=...
GET /api/machines/:id/projects/:projectId/workspaces/:workspaceId/git/status
GET /api/machines/:id/projects/:projectId/workspaces/:workspaceId/git/diff?path=...&staged=true
GET /api/machines/:id/files?cwd=...&q=...&kind=...&mode=...
GET /api/machines/:id/activity
GET /api/machines/:id/sessions?cwd=...
POST /api/machines/:id/sessions
GET /api/machines/:id/sessions/:sessionId/messages
GET /api/machines/:id/sessions/:sessionId/status
GET /api/machines/:id/sessions/:sessionId/models
POST /api/machines/:id/sessions/:sessionId/model
POST /api/machines/:id/sessions/:sessionId/model/cycle
GET /api/machines/:id/sessions/:sessionId/thinking-levels
POST /api/machines/:id/sessions/:sessionId/thinking-level
POST /api/machines/:id/sessions/:sessionId/thinking-level/cycle
GET /api/machines/:id/sessions/:sessionId/commands
POST /api/machines/:id/sessions/:sessionId/prompt
POST /api/machines/:id/sessions/:sessionId/shell
POST /api/machines/:id/sessions/:sessionId/commands/run
POST /api/machines/:id/sessions/:sessionId/commands/respond
POST /api/machines/:id/sessions/:sessionId/abort
POST /api/machines/:id/sessions/:sessionId/stop
POST /api/machines/:id/sessions/:sessionId/archive
POST /api/machines/:id/sessions/:sessionId/archive-tree
POST /api/machines/:id/sessions/:sessionId/restore
POST /api/machines/:id/sessions/:sessionId/detach-parent
GET /api/machines/:id/auth/providers
POST /api/machines/:id/auth/api-key
POST /api/machines/:id/auth/logout // API-key/logout only if safe for selected provider
```
Do not add a catch-all remote proxy in the first remote phase. Any route not explicitly allowlisted should return `404` or `501` with a clear message.
Remote path mapping:
```text
/api/machines/:id/<compat-path>?query
-> <machine.baseUrl>/api/<compat-path>?query
```
Rules:
- Preserve query strings exactly after the machine prefix is stripped.
- Forward JSON request bodies with `content-type: application/json` unless the original route needs a different explicit content type.
- Stream file preview responses; do not coerce previews into JSON strings.
- Use the same request body limits as the local Fastify API.
- Use bounded timeouts for normal HTTP proxy requests, and shorter timeouts for health checks.
Remote auth/header policy:
- `token` means `Authorization: Bearer <token>` by default.
- `headers` are additional gateway-to-remote headers.
- Never forward browser cookies, browser `Authorization`, or other browser credentials to a remote machine by default.
- Reject or ignore configured header names that affect transport/proxy semantics:
- `host`
- `connection`
- `upgrade`
- `transfer-encoding`
- `content-length`
- `keep-alive`
- `proxy-authenticate`
- `proxy-authorization`
- `te`
- `trailer`
- If both `token` and `headers.authorization` are provided, reject the machine config or require one explicit winner. Prefer rejecting ambiguity.
- Never disable TLS verification by default.
- Do not follow redirects for proxied API requests in v1.
Remote response header policy:
- Preserve safe response headers where useful:
- `content-type`
- `content-length`
- `cache-control`
- `last-modified`
- `etag`
- Strip hop-by-hop and credential-bearing headers:
- `connection`
- `transfer-encoding`
- `upgrade`
- `keep-alive`
- `proxy-authenticate`
- `proxy-authorization`
- `set-cookie`
- Normalize remote failures to JSON gateway errors for JSON endpoints.
Error response contract:
```json
{
"error": "Remote machine unavailable",
"machineId": "devbox",
"statusCode": 502,
"detail": "connect ECONNREFUSED 100.64.0.2:8504"
}
```
Guidance:
- Remote DNS/connect/TLS failure: `502`.
- Remote timeout: `504`.
- Unknown machine: `404`.
- Route known but not implemented remotely/gateway-side: preserve remote `404` when it came from remote, use gateway `501` when the gateway intentionally does not support it.
- Avoid leaking configured tokens/headers in errors or logs.
Health endpoint contract:
```text
GET /api/machines/:id/health
```
- `local` health combines existing Pi Web status and sessiond health where practical.
- Remote health calls `<baseUrl>/api/pi-web/status` with a short timeout.
- If the remote is old and lacks `/api/pi-web/status`, fall back to a lightweight `GET /api/projects` or root request only if that fallback is deliberate and tested.
- Responses should include `checkedAt`, `ok`, `status`, and optional component statuses.
- Cache health in memory for a short TTL so selecting machines does not block on repeated offline checks.
Remote auth provider policy:
- Gateway-to-remote machine credentials are separate from model-provider credentials.
- API-key provider flows may be proxied after basic remote HTTP proxying works.
- OAuth login/logout remains remote-direct in Phase 3. UI should show “Open remote Pi Web to configure OAuth” rather than proxying callback-sensitive flows.
Acceptance:
- Register another running Pi Web by URL.
- List remote projects/workspaces/sessions.
- Start session and send prompt via proxied HTTP.
- Health shows online/offline without blocking the UI.
- List remote projects/workspaces/sessions through machine-scoped endpoints.
- Start a remote session and send a prompt via proxied HTTP.
- Remote file tree/file content/git status work.
- Remote file previews stream with correct content type.
- Remote unreachable returns `502`; timeout returns `504`; token/header values never appear in responses or logs.
- Existing local and compatibility routes still pass tests.
### Phase 4: Remote WebSocket proxy
Deliverable: remote live sessions and terminals work.
Deliverable: remote live sessions and terminals work through the local Pi Web gateway.
Tasks:
Remote WebSocket route mapping:
- Proxy session event WebSockets to remote Pi Web.
- Proxy global events/activity WebSocket for selected machine.
- Proxy terminal socket WebSockets.
- Make `SessionSocket`, `RealtimeSocket`, and `terminalSocket` machine-scoped.
```text
WS /api/machines/:id/events
-> <remote ws base>/api/events
WS /api/machines/:id/sessions/events
-> <remote ws base>/api/sessions/events
WS /api/machines/:id/sessions/:sessionId/events
-> <remote ws base>/api/sessions/:sessionId/events
WS /api/machines/:id/projects/:projectId/workspaces/:workspaceId/terminals/:terminalId/socket?cols=...&rows=...
-> <remote ws base>/api/projects/:projectId/workspaces/:workspaceId/terminals/:terminalId/socket?cols=...&rows=...
```
Rules:
- Convert remote `http:` base URLs to `ws:` and `https:` base URLs to `wss:`.
- Preserve query strings exactly, especially terminal `cols` and `rows`.
- Attach the same gateway-to-remote credentials as HTTP proxying where WebSocket libraries support headers.
- Do not forward browser cookies or browser credentials.
- If upstream connect fails, close the browser WebSocket with a clear close code/reason where possible and log a sanitized gateway error.
- Forward upstream close codes/reasons to the browser when safe.
- Forward browser close to upstream and upstream close to browser.
- Buffer browser messages only while the upstream socket is connecting, with a small max buffer. Drop/close on overflow rather than unbounded buffering.
- Reuse or extend `src/server/webSocketBridge.ts` so buffering/close/error behavior is consistent.
- Add heartbeat/ping behavior only if tests or real remote tunnels show idle sockets are dropped; do not introduce timers without cleanup.
Client socket API changes:
```ts
sessionEvents(machineId: string, sessionId: string): WebSocket
globalSessionEvents(machineId: string): WebSocket
realtimeEvents(machineId: string): WebSocket
terminalSocket(machineId: string, projectId: string, workspaceId: string, terminalId: string, initialSize?: TerminalSize): WebSocket
```
Controller/socket ownership rules:
- `SessionSocket` reconnects using the selected machine ID.
- `RealtimeSocket` reconnects when selected machine changes.
- Terminal sockets are scoped to the selected machine and are closed when switching machines/workspaces.
- Activity/status maps use machine-scoped cache keys if data from multiple machines can coexist.
Acceptance:
- Remote assistant streaming appears live.
- Remote status/activity updates appear.
- Remote terminals work.
- Remote terminals work, including initial size query parameters.
- Closing the browser tab/session closes upstream WebSockets.
- Offline remote WebSocket attempts fail visibly without crashing the app.
- Local WebSockets and compatibility aliases still pass tests.
### Phase 5: UX polish and docs
+47 -45
View File
@@ -32,7 +32,9 @@ import {
parseWorkspace,
parseWorkspaceActivityResponse,
} from "./parsers";
import { gitDiffUrl, messageUrl } from "./urls";
import { machineGitDiffUrl, messageUrl } from "./urls";
const machinePrefix = (machineId = "local") => `/api/machines/${encodeURIComponent(machineId)}`;
export const piWebApi = {
piWebStatus: () => request("/api/pi-web/status", parsePiWebStatusResponse),
@@ -45,64 +47,64 @@ export const machinesApi = {
};
export const activityApi = {
workspaceActivity: () => request("/api/activity", parseWorkspaceActivityResponse),
workspaceActivity: (machineId = "local") => request(`${machinePrefix(machineId)}/activity`, parseWorkspaceActivityResponse),
};
export const projectsApi = {
projects: () => request("/api/projects", arrayOf(parseProject)),
addProject: (path: string, name?: string, create?: boolean) => request("/api/projects", parseProject, { method: "POST", body: JSON.stringify({ path, name, create }) }),
closeProject: (projectId: string) => request(`/api/projects/${encodeURIComponent(projectId)}`, parseClosed, { method: "DELETE" }),
projectDirectories: (query: string) => request(`/api/project-directories?q=${encodeURIComponent(query)}`, arrayOf(parseFileSuggestion)),
projects: (machineId = "local") => request(`${machinePrefix(machineId)}/projects`, arrayOf(parseProject)),
addProject: (path: string, name?: string, create?: boolean, machineId = "local") => request(`${machinePrefix(machineId)}/projects`, parseProject, { method: "POST", body: JSON.stringify({ path, name, create }) }),
closeProject: (projectId: string, machineId = "local") => request(`${machinePrefix(machineId)}/projects/${encodeURIComponent(projectId)}`, parseClosed, { method: "DELETE" }),
projectDirectories: (query: string, machineId = "local") => request(`${machinePrefix(machineId)}/project-directories?q=${encodeURIComponent(query)}`, arrayOf(parseFileSuggestion)),
};
export const workspacesApi = {
workspaces: (projectId: string) => request(`/api/projects/${projectId}/workspaces`, arrayOf(parseWorkspace)),
workspaceTree: (projectId: string, workspaceId: string, path = "") => request(`/api/projects/${encodeURIComponent(projectId)}/workspaces/${encodeURIComponent(workspaceId)}/tree?path=${encodeURIComponent(path)}`, parseFileTreeResponse),
workspaceFile: (projectId: string, workspaceId: string, path: string) => request(`/api/projects/${encodeURIComponent(projectId)}/workspaces/${encodeURIComponent(workspaceId)}/file?path=${encodeURIComponent(path)}`, parseFileContentResponse),
workspaces: (projectId: string, machineId = "local") => request(`${machinePrefix(machineId)}/projects/${projectId}/workspaces`, arrayOf(parseWorkspace)),
workspaceTree: (projectId: string, workspaceId: string, path = "", machineId = "local") => request(`${machinePrefix(machineId)}/projects/${encodeURIComponent(projectId)}/workspaces/${encodeURIComponent(workspaceId)}/tree?path=${encodeURIComponent(path)}`, parseFileTreeResponse),
workspaceFile: (projectId: string, workspaceId: string, path: string, machineId = "local") => request(`${machinePrefix(machineId)}/projects/${encodeURIComponent(projectId)}/workspaces/${encodeURIComponent(workspaceId)}/file?path=${encodeURIComponent(path)}`, parseFileContentResponse),
};
export const sessionsApi = {
sessions: (cwd: string) => request(`/api/sessions?cwd=${encodeURIComponent(cwd)}`, arrayOf(parseSessionInfo)),
startSession: (cwd: string) => request("/api/sessions", parseSessionInfo, { method: "POST", body: JSON.stringify({ cwd }) }),
messages: (sessionId: string, options?: { limit?: number; before?: number }) => request(messageUrl(sessionId, options), parseMessagePage),
status: (sessionId: string) => request(`/api/sessions/${sessionId}/status`, parseSessionStatus),
models: (sessionId: string) => request(`/api/sessions/${sessionId}/models`, parseModelSelectionResponse),
setModel: (sessionId: string, provider: string, modelId: string) => request(`/api/sessions/${sessionId}/model`, parseSessionStatus, { method: "POST", body: JSON.stringify({ provider, modelId }) }),
cycleModel: (sessionId: string, direction: "forward" | "backward") => request(`/api/sessions/${sessionId}/model/cycle`, parseSessionStatus, { method: "POST", body: JSON.stringify({ direction }) }),
thinkingLevels: (sessionId: string) => request(`/api/sessions/${sessionId}/thinking-levels`, parseThinkingLevelsResponse),
setThinkingLevel: (sessionId: string, level: "off" | "minimal" | "low" | "medium" | "high" | "xhigh") => request(`/api/sessions/${sessionId}/thinking-level`, parseSessionStatus, { method: "POST", body: JSON.stringify({ level }) }),
cycleThinkingLevel: (sessionId: string) => request(`/api/sessions/${sessionId}/thinking-level/cycle`, parseSessionStatus, { method: "POST" }),
commands: (sessionId: string) => request(`/api/sessions/${sessionId}/commands`, arrayOf(parseSlashCommand)),
prompt: (sessionId: string, text: string, streamingBehavior?: "steer" | "followUp") => request(`/api/sessions/${sessionId}/prompt`, parseAccepted, { method: "POST", body: JSON.stringify(streamingBehavior === undefined ? { text } : { text, streamingBehavior }) }),
shell: (sessionId: string, text: string) => request(`/api/sessions/${sessionId}/shell`, parseAccepted, { method: "POST", body: JSON.stringify({ text }) }),
runCommand: (sessionId: string, text: string) => request(`/api/sessions/${sessionId}/commands/run`, parseCommandResult, { method: "POST", body: JSON.stringify({ text }) }),
respondToCommand: (sessionId: string, requestId: string, value: string) => request(`/api/sessions/${sessionId}/commands/respond`, parseCommandResult, { method: "POST", body: JSON.stringify({ requestId, value }) }),
abort: (sessionId: string) => request(`/api/sessions/${sessionId}/abort`, parseAborted, { method: "POST" }),
stop: (sessionId: string) => request(`/api/sessions/${sessionId}/stop`, parseStopped, { method: "POST" }),
archive: (sessionId: string) => request(`/api/sessions/${sessionId}/archive`, parseArchived, { method: "POST" }),
archiveWithDescendants: (sessionId: string) => request(`/api/sessions/${sessionId}/archive-tree`, parseArchived, { method: "POST" }),
restore: (sessionId: string) => request(`/api/sessions/${sessionId}/restore`, parseRestored, { method: "POST" }),
detachParent: (sessionId: string) => request(`/api/sessions/${sessionId}/detach-parent`, parseDetached, { method: "POST" }),
authProviders: (options?: { mode?: "login" | "logout"; authType?: "oauth" | "api_key" }) => {
sessions: (cwd: string, machineId = "local") => request(`${machinePrefix(machineId)}/sessions?cwd=${encodeURIComponent(cwd)}`, arrayOf(parseSessionInfo)),
startSession: (cwd: string, machineId = "local") => request(`${machinePrefix(machineId)}/sessions`, parseSessionInfo, { method: "POST", body: JSON.stringify({ cwd }) }),
messages: (sessionId: string, options?: { limit?: number; before?: number }, machineId = "local") => request(messageUrl(sessionId, options, machineId), parseMessagePage),
status: (sessionId: string, machineId = "local") => request(`${machinePrefix(machineId)}/sessions/${sessionId}/status`, parseSessionStatus),
models: (sessionId: string, machineId = "local") => request(`${machinePrefix(machineId)}/sessions/${sessionId}/models`, parseModelSelectionResponse),
setModel: (sessionId: string, provider: string, modelId: string, machineId = "local") => request(`${machinePrefix(machineId)}/sessions/${sessionId}/model`, parseSessionStatus, { method: "POST", body: JSON.stringify({ provider, modelId }) }),
cycleModel: (sessionId: string, direction: "forward" | "backward", machineId = "local") => request(`${machinePrefix(machineId)}/sessions/${sessionId}/model/cycle`, parseSessionStatus, { method: "POST", body: JSON.stringify({ direction }) }),
thinkingLevels: (sessionId: string, machineId = "local") => request(`${machinePrefix(machineId)}/sessions/${sessionId}/thinking-levels`, parseThinkingLevelsResponse),
setThinkingLevel: (sessionId: string, level: "off" | "minimal" | "low" | "medium" | "high" | "xhigh", machineId = "local") => request(`${machinePrefix(machineId)}/sessions/${sessionId}/thinking-level`, parseSessionStatus, { method: "POST", body: JSON.stringify({ level }) }),
cycleThinkingLevel: (sessionId: string, machineId = "local") => request(`${machinePrefix(machineId)}/sessions/${sessionId}/thinking-level/cycle`, parseSessionStatus, { method: "POST" }),
commands: (sessionId: string, machineId = "local") => request(`${machinePrefix(machineId)}/sessions/${sessionId}/commands`, arrayOf(parseSlashCommand)),
prompt: (sessionId: string, text: string, streamingBehavior?: "steer" | "followUp", machineId = "local") => request(`${machinePrefix(machineId)}/sessions/${sessionId}/prompt`, parseAccepted, { method: "POST", body: JSON.stringify(streamingBehavior === undefined ? { text } : { text, streamingBehavior }) }),
shell: (sessionId: string, text: string, machineId = "local") => request(`${machinePrefix(machineId)}/sessions/${sessionId}/shell`, parseAccepted, { method: "POST", body: JSON.stringify({ text }) }),
runCommand: (sessionId: string, text: string, machineId = "local") => request(`${machinePrefix(machineId)}/sessions/${sessionId}/commands/run`, parseCommandResult, { method: "POST", body: JSON.stringify({ text }) }),
respondToCommand: (sessionId: string, requestId: string, value: string, machineId = "local") => request(`${machinePrefix(machineId)}/sessions/${sessionId}/commands/respond`, parseCommandResult, { method: "POST", body: JSON.stringify({ requestId, value }) }),
abort: (sessionId: string, machineId = "local") => request(`${machinePrefix(machineId)}/sessions/${sessionId}/abort`, parseAborted, { method: "POST" }),
stop: (sessionId: string, machineId = "local") => request(`${machinePrefix(machineId)}/sessions/${sessionId}/stop`, parseStopped, { method: "POST" }),
archive: (sessionId: string, machineId = "local") => request(`${machinePrefix(machineId)}/sessions/${sessionId}/archive`, parseArchived, { method: "POST" }),
archiveWithDescendants: (sessionId: string, machineId = "local") => request(`${machinePrefix(machineId)}/sessions/${sessionId}/archive-tree`, parseArchived, { method: "POST" }),
restore: (sessionId: string, machineId = "local") => request(`${machinePrefix(machineId)}/sessions/${sessionId}/restore`, parseRestored, { method: "POST" }),
detachParent: (sessionId: string, machineId = "local") => request(`${machinePrefix(machineId)}/sessions/${sessionId}/detach-parent`, parseDetached, { method: "POST" }),
authProviders: (options?: { mode?: "login" | "logout"; authType?: "oauth" | "api_key"; machineId?: string }) => {
const params = new URLSearchParams();
if (options?.mode !== undefined) params.set("mode", options.mode);
if (options?.authType !== undefined) params.set("authType", options.authType);
const query = params.toString();
return request(`/api/auth/providers${query === "" ? "" : `?${query}`}`, parseAuthProvidersResponse);
return request(`${machinePrefix(options?.machineId)}/auth/providers${query === "" ? "" : `?${query}`}`, parseAuthProvidersResponse);
},
saveApiKey: (providerId: string, key: string) => request("/api/auth/api-key", parseAccepted, { method: "POST", body: JSON.stringify({ providerId, key }) }),
logoutProvider: (providerId: string) => request("/api/auth/logout", parseAccepted, { method: "POST", body: JSON.stringify({ providerId }) }),
startOAuthLogin: (providerId: string) => request("/api/auth/oauth", parseOAuthFlowState, { method: "POST", body: JSON.stringify({ providerId }) }),
oauthFlow: (flowId: string) => request(`/api/auth/oauth/${encodeURIComponent(flowId)}`, parseOAuthFlowState),
respondOAuthFlow: (flowId: string, requestId: string, value: string) => request(`/api/auth/oauth/${encodeURIComponent(flowId)}/respond`, parseOAuthFlowState, { method: "POST", body: JSON.stringify({ requestId, value }) }),
cancelOAuthFlow: (flowId: string) => request(`/api/auth/oauth/${encodeURIComponent(flowId)}/cancel`, parseOAuthFlowState, { method: "POST" }),
saveApiKey: (providerId: string, key: string, machineId = "local") => request(`${machinePrefix(machineId)}/auth/api-key`, parseAccepted, { method: "POST", body: JSON.stringify({ providerId, key }) }),
logoutProvider: (providerId: string, machineId = "local") => request(`${machinePrefix(machineId)}/auth/logout`, parseAccepted, { method: "POST", body: JSON.stringify({ providerId }) }),
startOAuthLogin: (providerId: string, machineId = "local") => request(`${machinePrefix(machineId)}/auth/oauth`, parseOAuthFlowState, { method: "POST", body: JSON.stringify({ providerId }) }),
oauthFlow: (flowId: string, machineId = "local") => request(`${machinePrefix(machineId)}/auth/oauth/${encodeURIComponent(flowId)}`, parseOAuthFlowState),
respondOAuthFlow: (flowId: string, requestId: string, value: string, machineId = "local") => request(`${machinePrefix(machineId)}/auth/oauth/${encodeURIComponent(flowId)}/respond`, parseOAuthFlowState, { method: "POST", body: JSON.stringify({ requestId, value }) }),
cancelOAuthFlow: (flowId: string, machineId = "local") => request(`${machinePrefix(machineId)}/auth/oauth/${encodeURIComponent(flowId)}/cancel`, parseOAuthFlowState, { method: "POST" }),
};
export const terminalsApi = {
terminals: (projectId: string, workspaceId: string) => request(`/api/projects/${encodeURIComponent(projectId)}/workspaces/${encodeURIComponent(workspaceId)}/terminals`, arrayOf(parseTerminalInfo)),
startTerminal: (projectId: string, workspaceId: string, options?: { name?: string; cols?: number; rows?: number }) => request(`/api/projects/${encodeURIComponent(projectId)}/workspaces/${encodeURIComponent(workspaceId)}/terminals`, parseTerminalInfo, { method: "POST", body: JSON.stringify(options ?? {}) }),
closeTerminal: (projectId: string, workspaceId: string, terminalId: string) => request(`/api/projects/${encodeURIComponent(projectId)}/workspaces/${encodeURIComponent(workspaceId)}/terminals/${encodeURIComponent(terminalId)}`, parseClosed, { method: "DELETE" }),
continueTerminal: (projectId: string, workspaceId: string, terminalId: string) => request(`/api/projects/${encodeURIComponent(projectId)}/workspaces/${encodeURIComponent(workspaceId)}/terminals/${encodeURIComponent(terminalId)}/continue`, parseTerminalInfo, { method: "POST" }),
terminals: (projectId: string, workspaceId: string, machineId = "local") => request(`${machinePrefix(machineId)}/projects/${encodeURIComponent(projectId)}/workspaces/${encodeURIComponent(workspaceId)}/terminals`, arrayOf(parseTerminalInfo)),
startTerminal: (projectId: string, workspaceId: string, options?: { name?: string; cols?: number; rows?: number }, machineId = "local") => request(`${machinePrefix(machineId)}/projects/${encodeURIComponent(projectId)}/workspaces/${encodeURIComponent(workspaceId)}/terminals`, parseTerminalInfo, { method: "POST", body: JSON.stringify(options ?? {}) }),
closeTerminal: (projectId: string, workspaceId: string, terminalId: string, machineId = "local") => request(`${machinePrefix(machineId)}/projects/${encodeURIComponent(projectId)}/workspaces/${encodeURIComponent(workspaceId)}/terminals/${encodeURIComponent(terminalId)}`, parseClosed, { method: "DELETE" }),
continueTerminal: (projectId: string, workspaceId: string, terminalId: string, machineId = "local") => request(`${machinePrefix(machineId)}/projects/${encodeURIComponent(projectId)}/workspaces/${encodeURIComponent(workspaceId)}/terminals/${encodeURIComponent(terminalId)}/continue`, parseTerminalInfo, { method: "POST" }),
runTerminalCommand: (origin: string, input: RunTerminalCommandInput) => request(`/api/projects/${encodeURIComponent(input.workspace.projectId)}/workspaces/${encodeURIComponent(input.workspace.id)}/terminal-command-runs`, parseTerminalCommandRun, { method: "POST", body: JSON.stringify({ origin, title: input.title, command: input.command, metadata: input.metadata ?? {} }) }),
listCommandRuns: (filter?: TerminalCommandRunFilter) => request(`/api/terminal-command-runs${terminalCommandRunFilterQuery(filter)}`, arrayOf(parseTerminalCommandRun)),
getCommandRun: (runId: string) => getOptionalTerminalCommandRun(runId),
@@ -142,12 +144,12 @@ function isRecord(value: unknown): value is Record<string, unknown> {
}
export const filesApi = {
files: (cwd: string, query: string, kind?: FileSuggestion["kind"], mode?: "file" | "path") => request(`/api/files?cwd=${encodeURIComponent(cwd)}&q=${encodeURIComponent(query)}${kind !== undefined ? `&kind=${encodeURIComponent(kind)}` : ""}${mode !== undefined ? `&mode=${encodeURIComponent(mode)}` : ""}`, arrayOf(parseFileSuggestion)),
files: (cwd: string, query: string, kind?: FileSuggestion["kind"], mode?: "file" | "path", machineId = "local") => request(`${machinePrefix(machineId)}/files?cwd=${encodeURIComponent(cwd)}&q=${encodeURIComponent(query)}${kind !== undefined ? `&kind=${encodeURIComponent(kind)}` : ""}${mode !== undefined ? `&mode=${encodeURIComponent(mode)}` : ""}`, arrayOf(parseFileSuggestion)),
};
export const gitApi = {
gitStatus: (projectId: string, workspaceId: string) => request(`/api/projects/${encodeURIComponent(projectId)}/workspaces/${encodeURIComponent(workspaceId)}/git/status`, parseGitStatusResponse),
gitDiff: (projectId: string, workspaceId: string, options?: { path?: string; staged?: boolean }) => request(gitDiffUrl(projectId, workspaceId, options), parseGitDiffResponse),
gitStatus: (projectId: string, workspaceId: string, machineId = "local") => request(`${machinePrefix(machineId)}/projects/${encodeURIComponent(projectId)}/workspaces/${encodeURIComponent(workspaceId)}/git/status`, parseGitStatusResponse),
gitDiff: (projectId: string, workspaceId: string, options?: { path?: string; staged?: boolean }, machineId = "local") => request(machineGitDiffUrl(machineId, projectId, workspaceId, options), parseGitDiffResponse),
};
export const api = {
+40
View File
@@ -0,0 +1,40 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { globalSessionEvents, realtimeEvents, sessionEvents, terminalSocket } from "./sockets";
const webSocketUrls: string[] = [];
function FakeWebSocket(url: string): void {
webSocketUrls.push(url);
}
beforeEach(() => {
webSocketUrls.length = 0;
vi.stubGlobal("WebSocket", FakeWebSocket);
vi.stubGlobal("location", { protocol: "https:", host: "pi.example.test" });
});
afterEach(() => {
vi.unstubAllGlobals();
});
describe("machine-scoped socket urls", () => {
it("defaults session sockets to the local machine scope", () => {
sessionEvents("s1");
globalSessionEvents();
realtimeEvents();
expect(webSocketUrls).toEqual([
"wss://pi.example.test/api/machines/local/sessions/s1/events",
"wss://pi.example.test/api/machines/local/sessions/events",
"wss://pi.example.test/api/machines/local/events",
]);
});
it("uses the requested machine scope for terminal sockets", () => {
terminalSocket("p 1", "w/1", "t?1", { cols: 120, rows: 40 }, "remote-a");
expect(webSocketUrls).toEqual([
"wss://pi.example.test/api/machines/remote-a/projects/p%201/workspaces/w%2F1/terminals/t%3F1/socket?cols=120&rows=40",
]);
});
});
+12 -8
View File
@@ -1,18 +1,22 @@
export function sessionEvents(sessionId: string): WebSocket {
return new WebSocket(`${webSocketBaseUrl()}/api/sessions/${sessionId}/events`);
export function sessionEvents(sessionId: string, machineId = "local"): WebSocket {
return new WebSocket(`${webSocketBaseUrl()}${machinePrefix(machineId)}/sessions/${sessionId}/events`);
}
export function globalSessionEvents(): WebSocket {
return new WebSocket(`${webSocketBaseUrl()}/api/sessions/events`);
export function globalSessionEvents(machineId = "local"): WebSocket {
return new WebSocket(`${webSocketBaseUrl()}${machinePrefix(machineId)}/sessions/events`);
}
export function terminalSocket(projectId: string, workspaceId: string, terminalId: string, initialSize?: { cols: number; rows: number }): WebSocket {
export function terminalSocket(projectId: string, workspaceId: string, terminalId: string, initialSize?: { cols: number; rows: number }, machineId = "local"): WebSocket {
const sizeQuery = initialSize === undefined ? "" : `?cols=${encodeURIComponent(String(initialSize.cols))}&rows=${encodeURIComponent(String(initialSize.rows))}`;
return new WebSocket(`${webSocketBaseUrl()}/api/projects/${encodeURIComponent(projectId)}/workspaces/${encodeURIComponent(workspaceId)}/terminals/${encodeURIComponent(terminalId)}/socket${sizeQuery}`);
return new WebSocket(`${webSocketBaseUrl()}${machinePrefix(machineId)}/projects/${encodeURIComponent(projectId)}/workspaces/${encodeURIComponent(workspaceId)}/terminals/${encodeURIComponent(terminalId)}/socket${sizeQuery}`);
}
export function realtimeEvents(): WebSocket {
return new WebSocket(`${webSocketBaseUrl()}/api/events`);
export function realtimeEvents(machineId = "local"): WebSocket {
return new WebSocket(`${webSocketBaseUrl()}${machinePrefix(machineId)}/events`);
}
function machinePrefix(machineId: string): string {
return `/api/machines/${encodeURIComponent(machineId)}`;
}
function webSocketBaseUrl(): string {
+13 -4
View File
@@ -6,17 +6,26 @@ export function gitDiffUrl(projectId: string, workspaceId: string, options?: { p
return `/api/projects/${encodeURIComponent(projectId)}/workspaces/${encodeURIComponent(workspaceId)}/git/diff${query ? `?${query}` : ""}`;
}
export function messageUrl(sessionId: string, options?: { limit?: number; before?: number }): string {
export function machineGitDiffUrl(machineId: string, projectId: string, workspaceId: string, options?: { path?: string; staged?: boolean }): string {
const params = new URLSearchParams();
if (options?.path !== undefined) params.set("path", options.path);
if (options?.staged === true) params.set("staged", "true");
const query = params.toString();
return `/api/machines/${encodeURIComponent(machineId)}/projects/${encodeURIComponent(projectId)}/workspaces/${encodeURIComponent(workspaceId)}/git/diff${query ? `?${query}` : ""}`;
}
export function messageUrl(sessionId: string, options?: { limit?: number; before?: number }, machineId = "local"): string {
const params = new URLSearchParams();
if (options?.limit !== undefined) params.set("limit", String(options.limit));
if (options?.before !== undefined) params.set("before", String(options.before));
const query = params.toString();
return `/api/sessions/${sessionId}/messages${query ? `?${query}` : ""}`;
return `/api/machines/${encodeURIComponent(machineId)}/sessions/${sessionId}/messages${query ? `?${query}` : ""}`;
}
export function workspaceImagePreviewUrl(projectId: string, workspaceId: string, path: string, options?: { modifiedAt?: string }): string {
export function workspaceImagePreviewUrl(projectId: string, workspaceId: string, path: string, options?: { modifiedAt?: string; machineId?: string }): string {
const params = new URLSearchParams();
params.set("path", path);
if (options?.modifiedAt !== undefined) params.set("v", options.modifiedAt);
return `/api/projects/${encodeURIComponent(projectId)}/workspaces/${encodeURIComponent(workspaceId)}/file/preview?${params.toString()}`;
const prefix = `/api/machines/${encodeURIComponent(options?.machineId ?? "local")}`;
return `${prefix}/projects/${encodeURIComponent(projectId)}/workspaces/${encodeURIComponent(workspaceId)}/file/preview?${params.toString()}`;
}
+5 -3
View File
@@ -14,6 +14,7 @@ import { SessionController } from "../controllers/sessionController";
import { WorkspaceController, canDeleteWorkspace } from "../controllers/workspaceController";
import { InMemoryTerminalSelectionMemory } from "../controllers/terminalSelection";
import { KeyboardShortcutDispatcher } from "../keyboardShortcuts";
import { selectedMachineId } from "../controllers/types";
import { RealtimeSocket } from "../sessionSocket";
import type { QualifiedContributionId, QualifiedThemeContribution, QualifiedThemePairContribution, QualifiedWorkspacePanelContribution, PluginRuntimeContext, TerminalCommandRunsInternalRuntime, WorkspacePanelContext } from "../plugins/types";
import { CLASSIC_THEME_ID, DEFAULT_THEME_PREFERENCE, applyPiWebTheme, findThemePairForTheme, readStoredThemePreference, resolveThemePreference, writeStoredThemePreference, type ThemePreference, type ThemePreferenceResolution } from "../theme";
@@ -474,6 +475,7 @@ export class PiWebApp extends LitElement {
if (workspace !== undefined) void this.refreshActiveTerminals(workspace);
void this.refreshWorkspaceActivity();
},
selectedMachineId(this.state),
);
}
@@ -501,7 +503,7 @@ export class PiWebApp extends LitElement {
private async refreshActiveTerminals(workspace: Workspace): Promise<void> {
try {
const terminals = await terminalsApi.terminals(workspace.projectId, workspace.id);
const terminals = await terminalsApi.terminals(workspace.projectId, workspace.id, selectedMachineId(this.state));
if (this.state.selectedWorkspace?.id !== workspace.id) return;
this.activeTerminalIds.clear();
for (const terminal of terminals) {
@@ -1241,7 +1243,7 @@ export class PiWebApp extends LitElement {
<div class="mobile-navigation-panel">${this.isMobileNavigationLayout ? this.renderNavigationPanel(true) : null}</div>
${state.selectedSession ? html`
<chat-view .sessionId=${state.selectedSession.id} .messages=${state.messages} .messageStart=${state.messagePageStart} .messageTotal=${state.messagePageTotal} .hasMore=${state.messagePageStart > 0} .loadingMore=${state.isLoadingEarlierMessages} .isReceivingPartialStream=${state.isReceivingPartialStream} .isCompacting=${state.status?.isCompacting === true} .pendingMessageCount=${state.status?.pendingMessageCount ?? 0} .status=${state.status} .activity=${state.activity} .onLoadMore=${() => this.withChatPrependTransition(() => this.sessions.loadEarlierMessages())}></chat-view>
<prompt-editor .sessionId=${state.selectedSession.id} .cwd=${state.selectedWorkspace?.path} .disabled=${state.selectedSession.archived === true} .canSteer=${state.status?.isStreaming === true} .isCompacting=${state.status?.isCompacting === true} .canStop=${state.status?.isStreaming === true || state.status?.isBashRunning === true || state.status?.isCompacting === true || (state.status?.pendingMessageCount ?? 0) > 0} .status=${state.status} .onSend=${(text: string, streamingBehavior?: "steer" | "followUp") => { this.sendPrompt(text, streamingBehavior); }} .onStop=${() => this.sessions.stopActiveWork()} .onSelectModel=${() => { void this.openModelDialog(); }} .onSelectThinking=${() => { void this.openThinkingDialog(); }}></prompt-editor>
<prompt-editor .sessionId=${state.selectedSession.id} .cwd=${state.selectedWorkspace?.path} .machineId=${selectedMachineId(state)} .disabled=${state.selectedSession.archived === true} .canSteer=${state.status?.isStreaming === true} .isCompacting=${state.status?.isCompacting === true} .canStop=${state.status?.isStreaming === true || state.status?.isBashRunning === true || state.status?.isCompacting === true || (state.status?.pendingMessageCount ?? 0) > 0} .status=${state.status} .onSend=${(text: string, streamingBehavior?: "steer" | "followUp") => { this.sendPrompt(text, streamingBehavior); }} .onStop=${() => this.sessions.stopActiveWork()} .onSelectModel=${() => { void this.openModelDialog(); }} .onSelectThinking=${() => { void this.openThinkingDialog(); }}></prompt-editor>
<status-bar .status=${state.status} .workspace=${state.selectedWorkspace} .workspaceLabelItems=${state.selectedWorkspace === undefined ? [] : this.plugins.getWorkspaceLabelItems(state, state.selectedWorkspace)}></status-bar>
${state.commandDialog !== undefined ? html`<command-picker .title=${state.commandDialog.title} .options=${state.commandDialog.options} .onPick=${(value: string) => this.sessions.respondToCommand(state.commandDialog?.requestId ?? "", value)} .onCancel=${() => { this.sessions.cancelCommand(); }}></command-picker>` : null}
${state.modelDialog !== undefined ? html`<command-picker title=${state.modelDialog.title} .searchable=${true} .options=${state.modelDialog.options} .selectedValue=${state.modelDialog.selectedValue} .onPick=${(value: string) => { void this.pickModel(value); }} .onCancel=${() => { this.setState({ modelDialog: undefined }); }}></command-picker>` : null}
@@ -1251,7 +1253,7 @@ export class PiWebApp extends LitElement {
</main>
${this.renderWorkspacePanel()}
${state.actionPaletteOpen ? html`<action-palette .actions=${this.getActions()} .onRun=${(action: AppAction) => { this.setState({ actionPaletteOpen: false }); this.runAction(action); }} .onCancel=${() => { this.setState({ actionPaletteOpen: false }); }}></action-palette>` : null}
${state.projectDialogOpen ? html`<project-dialog .onSubmit=${(path: string, create: boolean) => this.projects.addProject(path, create)} .onCancel=${() => { this.setState({ projectDialogOpen: false }); }}></project-dialog>` : null}
${state.projectDialogOpen ? html`<project-dialog .machineId=${selectedMachineId(state)} .onSubmit=${(path: string, create: boolean) => this.projects.addProject(path, create)} .onCancel=${() => { this.setState({ projectDialogOpen: false }); }}></project-dialog>` : null}
${state.themeDialog !== undefined ? html`<command-picker title=${state.themeDialog.title} .options=${state.themeDialog.options} .selectedValue=${state.themeDialog.selectedValue} .onPick=${(value: string) => { this.pickTheme(value); }} .onCancel=${() => { this.setState({ themeDialog: undefined }); }}></command-picker>` : null}
${this.renderRefreshMenu()}
</div>
+2 -1
View File
@@ -7,6 +7,7 @@ import { css } from "lit";
export class ProjectDialog extends LitElement {
@property({ attribute: false }) onSubmit?: (path: string, create: boolean) => void;
@property({ attribute: false }) onCancel?: () => void;
@property() machineId = "local";
@state() private path = "";
@state() private createMissing = true;
@state() private suggestions: FileSuggestion[] = [];
@@ -29,7 +30,7 @@ export class ProjectDialog extends LitElement {
const requestId = ++this.requestId;
this.loading = true;
try {
const suggestions = await api.projectDirectories(this.path);
const suggestions = await api.projectDirectories(this.path, this.machineId);
if (requestId !== this.requestId) return;
this.suggestions = suggestions;
this.selected = Math.min(this.selected, Math.max(0, suggestions.length - 1));
+3 -2
View File
@@ -16,6 +16,7 @@ export class PromptEditor extends LitElement {
@property({ type: Boolean }) disabled = false;
@property() sessionId?: string;
@property() cwd?: string;
@property() machineId = "local";
@property({ type: Boolean }) canSteer = false;
@property({ type: Boolean }) isCompacting = false;
@property({ type: Boolean }) canStop = false;
@@ -168,7 +169,7 @@ export class PromptEditor extends LitElement {
return;
}
if (trigger.kind === "command" && this.sessionId !== undefined && this.sessionId !== "") {
const commands = await api.commands(this.sessionId).catch(emptySlashCommands);
const commands = await api.commands(this.sessionId, this.machineId).catch(emptySlashCommands);
if (version !== this.requestVersion) return;
this.completions = commands
.filter((command) => command.name.toLowerCase().includes(trigger.query.toLowerCase()))
@@ -182,7 +183,7 @@ export class PromptEditor extends LitElement {
...(command.description === undefined ? {} : { description: command.description }),
}));
} else if (trigger.kind === "file" && this.cwd !== undefined && this.cwd !== "") {
const files = await api.files(this.cwd, trigger.query, trigger.fileKind, trigger.fileMode).catch(emptyFileSuggestions);
const files = await api.files(this.cwd, trigger.query, trigger.fileKind, trigger.fileMode, this.machineId).catch(emptyFileSuggestions);
if (version !== this.requestVersion) return;
this.completions = files
.slice(0, 12)
+5 -4
View File
@@ -19,6 +19,7 @@ const COMMAND_RUN_POLL_INTERVAL_MS = 1000;
@customElement("terminal-panel")
export class TerminalPanel extends LitElement {
@property({ attribute: false }) workspace: Workspace | undefined;
@property() machineId = "local";
@property({ attribute: false }) selectedTerminalId: string | undefined;
@property({ type: Boolean }) autoStart = false;
@property({ attribute: false }) onSelectTerminal: (terminalId: string | undefined, options?: { replace?: boolean | undefined }) => void = () => undefined;
@@ -116,7 +117,7 @@ export class TerminalPanel extends LitElement {
if (workspace === undefined) return;
const shouldAutoStart = this.consumeAutoStart();
const [terminals, commandRuns] = await Promise.all([
terminalsApi.terminals(workspace.projectId, workspace.id),
terminalsApi.terminals(workspace.projectId, workspace.id, this.machineId),
terminalsApi.listCommandRuns({ projectId: workspace.projectId, workspaceId: workspace.id }),
]);
this.terminals = terminals;
@@ -173,7 +174,7 @@ export class TerminalPanel extends LitElement {
this.error = undefined;
try {
const size = this.measureTerminalSize() ?? DEFAULT_TERMINAL_SIZE;
const terminal = await terminalsApi.startTerminal(this.workspace.projectId, this.workspace.id, size);
const terminal = await terminalsApi.startTerminal(this.workspace.projectId, this.workspace.id, size, this.machineId);
this.terminals = [...this.terminals, terminal];
this.selectTerminal(terminal.id);
} catch (error) {
@@ -185,7 +186,7 @@ export class TerminalPanel extends LitElement {
event.stopPropagation();
try {
if (this.workspace === undefined) return;
await terminalsApi.closeTerminal(this.workspace.projectId, this.workspace.id, id);
await terminalsApi.closeTerminal(this.workspace.projectId, this.workspace.id, id, this.machineId);
const next = this.terminals.filter((terminal) => terminal.id !== id);
this.terminals = next;
if (this.selectedId === id || this.selectedTerminalId === id) {
@@ -296,7 +297,7 @@ export class TerminalPanel extends LitElement {
}
private connectSocket(projectId: string, workspaceId: string, terminalId: string, terminal: Terminal, initialSize: TerminalSize | undefined): void {
const socket = terminalSocket(projectId, workspaceId, terminalId, initialSize);
const socket = terminalSocket(projectId, workspaceId, terminalId, initialSize, this.machineId);
socket.binaryType = "arraybuffer";
this.socket = socket;
socket.addEventListener("open", () => { this.fitAndNotify(); });
@@ -1,6 +1,6 @@
import { activityApi as defaultApi, type WorkspaceActivity, type WorkspaceActivityResponse } from "../api";
import { isWorkspaceActivityActive } from "../../../shared/activity";
import type { GetState, SetState } from "./types";
import { selectedMachineId, type GetState, type SetState } from "./types";
export interface ActivityControllerDependencies {
api?: Pick<typeof defaultApi, "workspaceActivity">;
@@ -14,7 +14,7 @@ export class ActivityController {
}
async refresh(): Promise<void> {
const snapshot = await this.api.workspaceActivity();
const snapshot = await this.api.workspaceActivity(selectedMachineId(this.getState()));
this.setState({ workspaceActivities: indexWorkspaceActivities(snapshot) });
}
+11 -11
View File
@@ -1,5 +1,5 @@
import { api as defaultApi, type AuthProviderOption, type AuthType, type OAuthFlowState, type SessionStatus } from "../api";
import type { GetState, SetState } from "./types";
import { selectedMachineId, type GetState, type SetState } from "./types";
export interface AuthControllerDependencies {
api?: typeof defaultApi;
@@ -43,7 +43,7 @@ export class AuthController {
async chooseLoginMethod(authType: AuthType): Promise<void> {
try {
const { providers } = await this.api.authProviders({ mode: "login", authType });
const { providers } = await this.api.authProviders({ mode: "login", authType, machineId: selectedMachineId(this.getState()) });
this.setState({ authDialog: { step: "providers", mode: "login", authType, providers } });
} catch (error) {
this.setState({ error: String(error) });
@@ -79,7 +79,7 @@ export class AuthController {
delete clean.error;
this.setState({ authDialog: { ...clean, saving: true } });
try {
await this.api.saveApiKey(dialog.provider.id, key);
await this.api.saveApiKey(dialog.provider.id, key, selectedMachineId(this.getState()));
this.closeDialog();
void this.refreshStatus();
} catch (error) {
@@ -89,7 +89,7 @@ export class AuthController {
async openLogout(providerId?: string): Promise<void> {
try {
const { providers } = await this.api.authProviders({ mode: "logout" });
const { providers } = await this.api.authProviders({ mode: "logout", machineId: selectedMachineId(this.getState()) });
if (providerId !== undefined && providerId !== "") {
const provider = providers.find((candidate) => candidate.id === providerId);
if (provider !== undefined) await this.logoutProvider(provider.id);
@@ -104,7 +104,7 @@ export class AuthController {
async logoutProvider(providerId: string): Promise<void> {
try {
await this.api.logoutProvider(providerId);
await this.api.logoutProvider(providerId, selectedMachineId(this.getState()));
this.closeDialog();
void this.refreshStatus();
} catch (error) {
@@ -130,7 +130,7 @@ export class AuthController {
delete clean.error;
this.setState({ authDialog: { ...clean, responding: true } });
try {
const flow = await this.api.respondOAuthFlow(dialog.flow.flowId, request.requestId, responseValue);
const flow = await this.api.respondOAuthFlow(dialog.flow.flowId, request.requestId, responseValue, selectedMachineId(this.getState()));
this.updateOAuthFlow(flow);
} catch (error) {
this.setState({ authDialog: { ...dialog, responding: false, error: String(error) } });
@@ -145,7 +145,7 @@ export class AuthController {
}
this.stopPolling();
try {
await this.api.cancelOAuthFlow(dialog.flow.flowId);
await this.api.cancelOAuthFlow(dialog.flow.flowId, selectedMachineId(this.getState()));
} catch {
// Best-effort cancel. The dialog closes either way.
}
@@ -159,7 +159,7 @@ export class AuthController {
private async openLoginProvider(providerId: string): Promise<void> {
try {
const { providers } = await this.api.authProviders({ mode: "login" });
const { providers } = await this.api.authProviders({ mode: "login", machineId: selectedMachineId(this.getState()) });
const exact = providers.filter((provider) => provider.id === providerId);
if (exact.length === 0) {
this.setState({ error: `Auth provider not found: ${providerId}` });
@@ -180,7 +180,7 @@ export class AuthController {
private async startOAuth(provider: AuthProviderOption): Promise<void> {
try {
const flow = await this.api.startOAuthLogin(provider.id);
const flow = await this.api.startOAuthLogin(provider.id, selectedMachineId(this.getState()));
this.updateOAuthFlow(flow);
this.startPolling(flow.flowId);
} catch (error) {
@@ -224,7 +224,7 @@ export class AuthController {
return;
}
try {
this.updateOAuthFlow(await this.api.oauthFlow(flowId));
this.updateOAuthFlow(await this.api.oauthFlow(flowId, selectedMachineId(this.getState())));
} catch (error) {
this.stopPolling();
this.setState({ authDialog: { ...dialog, error: String(error) } });
@@ -235,7 +235,7 @@ export class AuthController {
const sessionId = this.sessionId();
if (sessionId === undefined) return;
try {
this.applyStatus(await this.api.status(sessionId));
this.applyStatus(await this.api.status(sessionId, selectedMachineId(this.getState())));
} catch {
// Status refresh is opportunistic after login completes.
}
@@ -1,6 +1,6 @@
import { api } from "../api";
import { queryNamespace, setNamespacedQueryKey } from "../namespacedQueryArgs";
import type { GetState, SetState, UpdateUrl } from "./types";
import { selectedMachineId, type GetState, type SetState, type UpdateUrl } from "./types";
const FILES_ROUTE_NAMESPACE = queryNamespace("core:workspace.files");
@@ -12,9 +12,10 @@ export class FileExplorerController {
const workspace = this.getState().selectedWorkspace;
if (project === undefined || workspace === undefined) return;
try {
const root = await api.workspaceTree(project.id, workspace.id);
const machineId = selectedMachineId(this.getState());
const root = await api.workspaceTree(project.id, workspace.id, "", machineId);
const expanded = { ...this.getState().expandedDirs };
await Promise.all(Object.keys(expanded).map(async (path) => { expanded[path] = (await api.workspaceTree(project.id, workspace.id, path)).entries; }));
await Promise.all(Object.keys(expanded).map(async (path) => { expanded[path] = (await api.workspaceTree(project.id, workspace.id, path, machineId)).entries; }));
this.setState({ fileTree: root.entries, expandedDirs: expanded, fileTreeStale: false, error: "" });
} catch (error) {
this.setState({ error: String(error) });
@@ -30,7 +31,7 @@ export class FileExplorerController {
return;
}
try {
const response = await api.workspaceTree(project.id, workspace.id, path);
const response = await api.workspaceTree(project.id, workspace.id, path, selectedMachineId(this.getState()));
this.setState({ expandedDirs: { ...this.getState().expandedDirs, [path]: response.entries }, error: "" });
} catch (error) {
this.setState({ error: String(error) });
@@ -50,7 +51,7 @@ export class FileExplorerController {
if (project === undefined || workspace === undefined) return;
this.setState({ selectedFilePath: path, selectedFileContent: undefined });
try {
const content = await api.workspaceFile(project.id, workspace.id, path);
const content = await api.workspaceFile(project.id, workspace.id, path, selectedMachineId(this.getState()));
if (this.getState().selectedFilePath === path) this.setState({ selectedFileContent: content, error: "" });
} catch (error) {
if (this.getState().selectedFilePath !== path) return;
+4 -4
View File
@@ -1,6 +1,6 @@
import { api } from "../api";
import { queryNamespace, setNamespacedQueryKey } from "../namespacedQueryArgs";
import type { GetState, SetState, UpdateUrl } from "./types";
import { selectedMachineId, type GetState, type SetState, type UpdateUrl } from "./types";
const GIT_ROUTE_NAMESPACE = queryNamespace("core:workspace.git");
@@ -19,7 +19,7 @@ export class GitController {
const workspace = this.getState().selectedWorkspace;
if (project === undefined || workspace === undefined) return;
try {
const status = await api.gitStatus(project.id, workspace.id);
const status = await api.gitStatus(project.id, workspace.id, selectedMachineId(this.getState()));
this.setState({ gitStatus: status, gitStale: false, error: "" });
const selectedDiffPath = this.getState().selectedDiffPath;
if (selectedDiffPath !== undefined) {
@@ -52,8 +52,8 @@ export class GitController {
if (project === undefined || workspace === undefined) return;
try {
const [selectedDiff, selectedStagedDiff] = await Promise.all([
api.gitDiff(project.id, workspace.id, { path }),
api.gitDiff(project.id, workspace.id, { path, staged: true }),
api.gitDiff(project.id, workspace.id, { path }, selectedMachineId(this.getState())),
api.gitDiff(project.id, workspace.id, { path, staged: true }, selectedMachineId(this.getState())),
]);
this.setState({ selectedDiff, selectedStagedDiff, error: "" });
} catch (error) {
@@ -1,5 +1,5 @@
import { api } from "../api";
import type { GetState, SetState } from "./types";
import { selectedMachineId, type GetState, type SetState } from "./types";
import type { WorkspaceController } from "./workspaceController";
export class ProjectController {
@@ -13,7 +13,7 @@ export class ProjectController {
}
this.setState({ error: "", isLoadingProjects: true });
try {
const projects = await api.projects();
const projects = await api.projects(selectedMachineId(this.getState()));
const projectIds = new Set(projects.map((project) => project.id));
const workspacesByProjectId = Object.fromEntries(Object.entries(this.getState().workspacesByProjectId).filter(([projectId]) => projectIds.has(projectId)));
this.setState({ projects, workspacesByProjectId });
@@ -31,7 +31,7 @@ export class ProjectController {
}
if (path.trim() === "") return;
try {
const project = await api.addProject(path.trim(), undefined, create);
const project = await api.addProject(path.trim(), undefined, create, selectedMachineId(this.getState()));
const projects = this.getState().projects;
this.setState({ projects: [...projects.filter((p) => p.id !== project.id), project], projectDialogOpen: false });
await this.workspaces.selectProject(project);
@@ -42,7 +42,7 @@ export class ProjectController {
async closeProject(projectId: string) {
try {
await api.closeProject(projectId);
await api.closeProject(projectId, selectedMachineId(this.getState()));
this.workspaces.forgetProject(projectId);
const state = this.getState();
this.setState({ projects: state.projects.filter((p) => p.id !== projectId) });
+26 -25
View File
@@ -8,12 +8,12 @@ import { isShellInput } from "../inputModes";
import { SessionSocket, type GlobalSessionEvent, type SessionUiEvent } from "../sessionSocket";
import { isSessionActive } from "../../../shared/activity";
import { InMemorySessionSelectionMemory, markSessionArchived, markSessionsArchived, selectPreferredSession, selectionAfterArchivingSession, selectionAfterArchivingSessions, shouldDeselectAfterArchivedCollapse, type SessionSelectionMemory } from "./sessionSelection";
import type { GetState, SetState, UpdateUrl } from "./types";
import { selectedMachineId, type GetState, type SetState, type UpdateUrl } from "./types";
const MESSAGE_PAGE_SIZE = 100;
export interface SessionEventSocket {
connect(sessionId: string, onEvent: (event: SessionUiEvent) => void, onReconnect?: () => void): void;
connect(sessionId: string, onEvent: (event: SessionUiEvent) => void, onReconnect?: () => void, machineId?: string): void;
setHandler(onEvent: (event: SessionUiEvent) => void): void;
close(): void;
}
@@ -82,7 +82,7 @@ export class SessionController {
const workspace = this.getState().selectedWorkspace;
if (!workspace) return;
try {
const session = await this.api.startSession(workspace.path);
const session = await this.api.startSession(workspace.path, selectedMachineId(this.getState()));
rememberCachedNewSession(session);
const cachedSession = markCachedNewSessionInfo(session);
this.setState({ sessions: [cachedSession, ...this.getState().sessions] });
@@ -113,7 +113,7 @@ export class SessionController {
});
try {
if (session.archived === true) {
const page = await this.api.messages(session.id, { limit: MESSAGE_PAGE_SIZE });
const page = await this.api.messages(session.id, { limit: MESSAGE_PAGE_SIZE }, selectedMachineId(this.getState()));
if (seq !== this.selectionSeq || this.getState().selectedSession?.id !== session.id) return;
const history = this.transcripts.mergeHistory(session.id, page);
this.setState({ ...history, isLoadingEarlierMessages: false, isReceivingPartialStream: false, status: undefined, activity: undefined });
@@ -125,8 +125,9 @@ export class SessionController {
session.id,
(event) => buffered.push(event),
() => { void this.refreshSelectedSession(session.id); },
selectedMachineId(this.getState()),
);
const [page, status] = await Promise.all([this.api.messages(session.id, { limit: MESSAGE_PAGE_SIZE }), this.api.status(session.id)]);
const [page, status] = await Promise.all([this.api.messages(session.id, { limit: MESSAGE_PAGE_SIZE }, selectedMachineId(this.getState())), this.api.status(session.id, selectedMachineId(this.getState()))]);
if (seq !== this.selectionSeq || this.getState().selectedSession?.id !== session.id) return;
const history = this.transcripts.mergeHistory(session.id, page);
const isReceivingPartialStream = status.isStreaming;
@@ -152,7 +153,7 @@ export class SessionController {
if (!session || state.isLoadingEarlierMessages || state.messagePageStart <= 0) return;
this.setState({ isLoadingEarlierMessages: true });
try {
const page = await this.api.messages(session.id, { before: state.messagePageStart, limit: MESSAGE_PAGE_SIZE });
const page = await this.api.messages(session.id, { before: state.messagePageStart, limit: MESSAGE_PAGE_SIZE }, selectedMachineId(this.getState()));
if (this.getState().selectedSession?.id !== session.id) return;
const history = this.transcripts.mergeHistory(session.id, page);
this.setState(history);
@@ -170,7 +171,7 @@ export class SessionController {
const session = this.getState().selectedSession;
if (!session || session.archived === true) return;
try {
await this.api.prompt(session.id, text, streamingBehavior);
await this.api.prompt(session.id, text, streamingBehavior, selectedMachineId(this.getState()));
this.markCachedNewSessionPersisted(session);
} catch (error) {
this.setState({ error: String(error) });
@@ -182,7 +183,7 @@ export class SessionController {
if (!session || session.archived === true) return;
this.setState({ messages: [...this.getState().messages, textMessage("user", text)] });
try {
await this.api.shell(session.id, text);
await this.api.shell(session.id, text, selectedMachineId(this.getState()));
this.markCachedNewSessionPersisted(session);
} catch (error) {
this.setState({ messages: [...this.getState().messages, textMessage("system", String(error))], error: String(error) });
@@ -194,7 +195,7 @@ export class SessionController {
if (!session || session.archived === true) return;
this.setState({ messages: [...this.getState().messages, textMessage("user", text)] });
try {
this.applyCommandResult(await this.api.runCommand(session.id, text));
this.applyCommandResult(await this.api.runCommand(session.id, text, selectedMachineId(this.getState())));
this.markCachedNewSessionPersisted(session);
} catch (error) {
this.setState({ messages: [...this.getState().messages, textMessage("system", String(error))], error: String(error) });
@@ -206,7 +207,7 @@ export class SessionController {
if (!session) return;
this.setState({ commandDialog: undefined });
try {
this.applyCommandResult(await this.api.respondToCommand(session.id, requestId, value));
this.applyCommandResult(await this.api.respondToCommand(session.id, requestId, value, selectedMachineId(this.getState())));
} catch (error) {
this.setState({ error: String(error) });
}
@@ -227,7 +228,7 @@ export class SessionController {
return;
}
try {
await this.api.archive(session.id);
await this.api.archive(session.id, selectedMachineId(this.getState()));
const state = this.getState();
const sessions = markSessionArchived(state.sessions, session.id, new Date().toISOString());
const selectionChange = selectionAfterArchivingSession(sessions, state.selectedSession?.id, session.id);
@@ -243,7 +244,7 @@ export class SessionController {
async archiveSessionWithDescendants(session = this.getState().selectedSession) {
if (!session || isCachedNewSessionInfo(session)) return;
try {
const response = await this.api.archiveWithDescendants(session.id);
const response = await this.api.archiveWithDescendants(session.id, selectedMachineId(this.getState()));
const archivedIds = response.sessionIds !== undefined && response.sessionIds.length > 0 ? response.sessionIds : [session.id];
const state = this.getState();
const sessions = markSessionsArchived(state.sessions, archivedIds, new Date().toISOString());
@@ -259,7 +260,7 @@ export class SessionController {
async deleteCachedNewSession(session = this.getState().selectedSession) {
if (!isCachedNewSessionInfo(session)) return;
void this.api.stop(session.id).catch(() => {
void this.api.stop(session.id, selectedMachineId(this.getState())).catch(() => {
// Best-effort cleanup for browser-cached sessions that may not exist server-side anymore.
});
forgetCachedNewSession(session.id);
@@ -278,7 +279,7 @@ export class SessionController {
async restoreSession(session = this.getState().selectedSession) {
if (!session) return;
try {
await this.api.restore(session.id);
await this.api.restore(session.id, selectedMachineId(this.getState()));
const restored = { ...session };
delete restored.archived;
delete restored.archivedAt;
@@ -292,7 +293,7 @@ export class SessionController {
async detachParent(session = this.getState().selectedSession) {
if (session?.parentSessionPath === undefined) return;
try {
await this.api.detachParent(session.id);
await this.api.detachParent(session.id, selectedMachineId(this.getState()));
const detached = { ...session };
delete detached.parentSessionPath;
this.replaceSession(detached);
@@ -305,7 +306,7 @@ export class SessionController {
const session = this.getState().selectedSession;
if (!session || session.archived === true) return [];
try {
return (await this.api.models(session.id)).models;
return (await this.api.models(session.id, selectedMachineId(this.getState()))).models;
} catch (error) {
this.setState({ error: String(error) });
return [];
@@ -316,7 +317,7 @@ export class SessionController {
const session = this.getState().selectedSession;
if (!session || session.archived === true) return;
try {
this.applyStatus(await this.api.setModel(session.id, provider, modelId));
this.applyStatus(await this.api.setModel(session.id, provider, modelId, selectedMachineId(this.getState())));
} catch (error) {
this.setState({ error: String(error) });
}
@@ -326,7 +327,7 @@ export class SessionController {
const session = this.getState().selectedSession;
if (!session || session.archived === true) return;
try {
this.applyStatus(await this.api.cycleModel(session.id, direction));
this.applyStatus(await this.api.cycleModel(session.id, direction, selectedMachineId(this.getState())));
} catch (error) {
this.setState({ error: String(error) });
}
@@ -336,7 +337,7 @@ export class SessionController {
const session = this.getState().selectedSession;
if (!session || session.archived === true) return [];
try {
return (await this.api.thinkingLevels(session.id)).levels;
return (await this.api.thinkingLevels(session.id, selectedMachineId(this.getState()))).levels;
} catch (error) {
this.setState({ error: String(error) });
return [];
@@ -347,7 +348,7 @@ export class SessionController {
const session = this.getState().selectedSession;
if (!session || session.archived === true) return;
try {
this.applyStatus(await this.api.setThinkingLevel(session.id, level));
this.applyStatus(await this.api.setThinkingLevel(session.id, level, selectedMachineId(this.getState())));
} catch (error) {
this.setState({ error: String(error) });
}
@@ -357,7 +358,7 @@ export class SessionController {
const session = this.getState().selectedSession;
if (!session || session.archived === true) return;
try {
this.applyStatus(await this.api.cycleThinkingLevel(session.id));
this.applyStatus(await this.api.cycleThinkingLevel(session.id, selectedMachineId(this.getState())));
} catch (error) {
this.setState({ error: String(error) });
}
@@ -367,7 +368,7 @@ export class SessionController {
const session = this.getState().selectedSession;
if (!session) return;
try {
await this.api.abort(session.id);
await this.api.abort(session.id, selectedMachineId(this.getState()));
} catch (error) {
this.setState({ error: String(error) });
}
@@ -378,7 +379,7 @@ export class SessionController {
if (sessionId === undefined || session?.id !== sessionId || session.archived === true) return;
try {
this.flushPendingTranscriptEvents();
const [page, status] = await Promise.all([this.api.messages(sessionId, { limit: MESSAGE_PAGE_SIZE }), this.api.status(sessionId)]);
const [page, status] = await Promise.all([this.api.messages(sessionId, { limit: MESSAGE_PAGE_SIZE }, selectedMachineId(this.getState())), this.api.status(sessionId, selectedMachineId(this.getState()))]);
if (this.getState().selectedSession?.id !== sessionId) return;
const history = this.transcripts.mergeHistory(sessionId, page);
this.setState({
@@ -403,7 +404,7 @@ export class SessionController {
private async recreateCachedNewSession(session: SessionInfo, options?: { updateUrl?: boolean | undefined }): Promise<void> {
try {
const replacement = await this.api.startSession(session.cwd);
const replacement = await this.api.startSession(session.cwd, selectedMachineId(this.getState()));
rememberCachedNewSession(replacement);
moveDraft(session.id, replacement.id);
forgetCachedNewSession(session.id);
@@ -535,7 +536,7 @@ export class SessionController {
private async refreshMessages(sessionId: string) {
try {
const page = await this.api.messages(sessionId, { limit: MESSAGE_PAGE_SIZE });
const page = await this.api.messages(sessionId, { limit: MESSAGE_PAGE_SIZE }, selectedMachineId(this.getState()));
if (this.getState().selectedSession?.id !== sessionId) return;
this.setState(this.transcripts.mergeHistory(sessionId, page));
} catch (error) {
+4
View File
@@ -1,5 +1,9 @@
import type { AppState } from "../appState";
export function selectedMachineId(state: Pick<AppState, "selectedMachine">): string {
return state.selectedMachine?.id ?? "local";
}
export type GetState = () => AppState;
export type SetState = (patch: Partial<AppState>) => void;
export type UpdateUrl = (options?: { replace?: boolean | undefined }) => void;
@@ -1,7 +1,7 @@
import { api as defaultApi, type Project, type Workspace } from "../api";
import { resetWorkspaceScopedState } from "../appState";
import { mergeCachedNewSessions } from "../cachedNewSessions";
import type { GetState, RouteTarget, SetState, UpdateUrl } from "./types";
import { selectedMachineId, type GetState, type RouteTarget, type SetState, type UpdateUrl } from "./types";
import type { SessionController } from "./sessionController";
import { InMemoryWorkspaceSelectionMemory, selectPreferredWorkspace, type WorkspaceSelectionMemory } from "./workspaceSelection";
@@ -39,7 +39,7 @@ export class WorkspaceController {
this.sessions.clearActiveSession();
this.setState({ selectedProject: project, selectedWorkspace: undefined, workspaces: [], isLoadingWorkspaces: true, ...resetWorkspaceScopedState() });
try {
const workspaces = await this.api.workspaces(project.id);
const workspaces = await api.workspaces(project.id, selectedMachineId(this.getState()));
this.setState({ workspaces, workspacesByProjectId: { ...this.getState().workspacesByProjectId, [project.id]: workspaces }, isLoadingWorkspaces: false });
const workspace = selectPreferredWorkspace(workspaces, { targetWorkspaceId: target?.workspaceId, latestWorkspaceId: this.workspaceSelection.latestWorkspaceId(project.id) });
if (workspace) await this.selectWorkspace(workspace, { sessionId: target?.sessionId, updateUrl: target?.updateUrl });
@@ -54,7 +54,7 @@ export class WorkspaceController {
this.sessions.clearActiveSession();
this.setState({ selectedWorkspace: workspace, isLoadingWorkspaces: false, ...resetWorkspaceScopedState() });
try {
const sessions = mergeCachedNewSessions(workspace.path, await this.api.sessions(workspace.path));
const sessions = mergeCachedNewSessions(workspace.path, await api.sessions(workspace.path, selectedMachineId(this.getState())));
this.setState({ sessions });
const session = this.sessions.preferredSession(workspace.path, sessions, target?.sessionId);
if (session) await this.sessions.selectSession(session, { updateUrl: target?.updateUrl });
+2 -2
View File
@@ -86,7 +86,7 @@ function renderImageViewer(context: WorkspacePanelContext, file: FileContentResp
<p class="muted">Image too large to preview: ${formatFileSize(file.size)} · limit ${MAX_IMAGE_PREVIEW_LABEL}</p>
`;
}
const src = workspaceImagePreviewUrl(context.workspace.projectId, context.workspace.id, file.path, { modifiedAt: file.modifiedAt });
const src = workspaceImagePreviewUrl(context.workspace.projectId, context.workspace.id, file.path, { modifiedAt: file.modifiedAt, machineId: context.state.selectedMachine?.id ?? "local" });
return html`
<div class="viewer-header"><strong>${file.path}</strong><small>${metadata}</small></div>
<div class="image-preview">
@@ -97,7 +97,7 @@ function renderImageViewer(context: WorkspacePanelContext, file: FileContentResp
function renderTerminal(context: WorkspacePanelContext): TemplateResult {
loadTerminalPanel();
return html`<terminal-panel .workspace=${context.workspace} .selectedTerminalId=${context.selectedTerminalId} .autoStart=${context.terminalAutoStart} .onSelectTerminal=${context.onSelectTerminal}></terminal-panel>`;
return html`<terminal-panel .workspace=${context.workspace} .machineId=${context.state.selectedMachine?.id ?? "local"} .selectedTerminalId=${context.selectedTerminalId} .autoStart=${context.terminalAutoStart} .onSelectTerminal=${context.onSelectTerminal}></terminal-panel>`;
}
function renderGit(context: WorkspacePanelContext): TemplateResult {
+15 -6
View File
@@ -12,9 +12,11 @@ export class SessionSocket {
private shouldReconnect = false;
private hasOpened = false;
private onReconnect: (() => void) | undefined;
private machineId = "local";
connect(sessionId: string, onEvent: (event: SessionUiEvent) => void, onReconnect?: () => void): void {
connect(sessionId: string, onEvent: (event: SessionUiEvent) => void, onReconnect?: () => void, machineId = "local"): void {
this.close();
this.machineId = machineId;
this.sessionId = sessionId;
this.onEvent = onEvent;
this.onReconnect = onReconnect;
@@ -35,11 +37,12 @@ export class SessionSocket {
this.onEvent = undefined;
this.onReconnect = undefined;
this.hasOpened = false;
this.machineId = "local";
}
private open(): void {
if (this.sessionId === undefined || this.sessionId === "" || !this.shouldReconnect) return;
const socket = sessionEvents(this.sessionId);
const socket = sessionEvents(this.sessionId, this.machineId);
this.socket = socket;
socket.onopen = () => {
this.reconnectDelay = 500;
@@ -75,9 +78,11 @@ export class RealtimeSocket {
private reconnectTimer?: number;
private reconnectDelay = 500;
private shouldReconnect = false;
private machineId = "local";
connect(onEvent: (event: RealtimeEvent) => void, onOpen?: () => void): void {
connect(onEvent: (event: RealtimeEvent) => void, onOpen?: () => void, machineId = "local"): void {
this.close();
this.machineId = machineId;
this.onEvent = onEvent;
this.onOpen = onOpen;
this.shouldReconnect = true;
@@ -91,11 +96,12 @@ export class RealtimeSocket {
this.socket = undefined;
this.onEvent = undefined;
this.onOpen = undefined;
this.machineId = "local";
}
private open(): void {
if (!this.shouldReconnect) return;
const socket = realtimeEvents();
const socket = realtimeEvents(this.machineId);
this.socket = socket;
socket.onopen = () => {
this.reconnectDelay = 500;
@@ -129,9 +135,11 @@ export class GlobalSessionSocket {
private reconnectTimer?: number;
private reconnectDelay = 500;
private shouldReconnect = false;
private machineId = "local";
connect(onEvent: (event: GlobalSessionEvent) => void): void {
connect(onEvent: (event: GlobalSessionEvent) => void, machineId = "local"): void {
this.close();
this.machineId = machineId;
this.onEvent = onEvent;
this.shouldReconnect = true;
this.open();
@@ -143,11 +151,12 @@ export class GlobalSessionSocket {
closeSocketQuietly(this.socket);
this.socket = undefined;
this.onEvent = undefined;
this.machineId = "local";
}
private open(): void {
if (!this.shouldReconnect) return;
const socket = globalSessionEvents();
const socket = globalSessionEvents(this.machineId);
this.socket = socket;
socket.onopen = () => {
this.reconnectDelay = 500;
+25
View File
@@ -77,6 +77,31 @@ describe("buildApp", () => {
expect(emptyListResponse.json<Project[]>()).toEqual([]);
});
it("serves local session proxy routes through machine-scoped aliases", async () => {
const response = await app.inject({ method: "GET", url: `/api/machines/local/sessions?cwd=${encodeURIComponent(projectDir)}` });
expect(response.statusCode).toBe(502);
expect(response.json()).toHaveProperty("error");
});
it("serves local projects and workspaces through machine-scoped aliases", async () => {
const addResponse = await app.inject({
method: "POST",
url: "/api/machines/local/projects",
payload: { name: "Machine Local", path: projectDir, create: true },
});
expect(addResponse.statusCode).toBe(200);
const project = addResponse.json<Project>();
const listResponse = await app.inject({ method: "GET", url: "/api/machines/local/projects" });
expect(listResponse.statusCode).toBe(200);
expect(listResponse.json<Project[]>()).toEqual([project]);
const workspacesResponse = await app.inject({ method: "GET", url: `/api/machines/local/projects/${project.id}/workspaces` });
expect(workspacesResponse.statusCode).toBe(200);
expect(workspacesResponse.json<Workspace[]>()).toEqual([expect.objectContaining({ projectId: project.id, path: projectDir })]);
});
it("serves the PI WEB plugin manifest and plugin assets", async () => {
const manifestResponse = await app.inject({ method: "GET", url: "/pi-web-plugins/manifest.json" });
expect(manifestResponse.statusCode).toBe(200);
+58 -44
View File
@@ -27,6 +27,56 @@ export interface AppDependencies {
logger?: FastifyServerOptions["logger"];
}
function registerLocalProjectRoutes(app: FastifyInstance, projects: ProjectService, workspaces: WorkspaceService, prefix: string): void {
app.get(`${prefix}/projects`, async () => projects.list());
app.post<{ Body: { name?: string; path: string; create?: boolean } }>(`${prefix}/projects`, async (request, reply) => {
try {
return await projects.add(request.body);
} catch (error) {
return reply.code(400).send({ error: error instanceof Error ? error.message : String(error) });
}
});
app.delete<{ Params: { projectId: string } }>(`${prefix}/projects/:projectId`, async (request, reply) => {
try {
await projects.close(request.params.projectId);
return { closed: true };
} catch (error) {
return reply.code(404).send({ error: error instanceof Error ? error.message : String(error) });
}
});
app.get<{ Querystring: { q?: string } }>(`${prefix}/project-directories`, async (request, reply) => {
try {
return await listDirectorySuggestions(request.query.q ?? "");
} catch (error) {
return reply.code(400).send({ error: error instanceof Error ? error.message : String(error) });
}
});
app.get<{ Params: { projectId: string } }>(`${prefix}/projects/:projectId/workspaces`, async (request, reply) => {
try {
const project = await projects.requireProject(request.params.projectId);
return await workspaces.list(project);
} catch (error) {
return reply.code(404).send({ error: error instanceof Error ? error.message : String(error) });
}
});
}
function registerLocalFileSuggestionRoutes(app: FastifyInstance, prefix: string): void {
app.get<{ Querystring: { cwd?: string; q?: string; kind?: "tracked" | "untracked" | "other"; mode?: "file" | "path" } }>(`${prefix}/files`, async (request, reply) => {
if (request.query.cwd === undefined || request.query.cwd === "") return reply.code(400).send({ error: "cwd query parameter is required" });
try {
if (request.query.mode === "path") return await listPathSuggestions(request.query.cwd, request.query.q ?? "");
return await listFileSuggestions(request.query.cwd, request.query.q ?? "", request.query.kind);
} catch (error) {
return reply.code(400).send({ error: error instanceof Error ? error.message : String(error) });
}
});
}
export async function buildApp(deps: AppDependencies = {}): Promise<FastifyInstance> {
const app = Fastify({ logger: deps.logger ?? true });
await app.register(fastifyWebsocket);
@@ -48,56 +98,20 @@ export async function buildApp(deps: AppDependencies = {}): Promise<FastifyInsta
registerMachineRoutes(app, machines);
app.get("/api/projects", async () => projects.list());
app.post<{ Body: { name?: string; path: string; create?: boolean } }>("/api/projects", async (request, reply) => {
try {
return await projects.add(request.body);
} catch (error) {
return reply.code(400).send({ error: error instanceof Error ? error.message : String(error) });
}
});
app.delete<{ Params: { projectId: string } }>("/api/projects/:projectId", async (request, reply) => {
try {
await projects.close(request.params.projectId);
return { closed: true };
} catch (error) {
return reply.code(404).send({ error: error instanceof Error ? error.message : String(error) });
}
});
app.get<{ Querystring: { q?: string } }>("/api/project-directories", async (request, reply) => {
try {
return await listDirectorySuggestions(request.query.q ?? "");
} catch (error) {
return reply.code(400).send({ error: error instanceof Error ? error.message : String(error) });
}
});
app.get<{ Params: { projectId: string } }>("/api/projects/:projectId/workspaces", async (request, reply) => {
try {
const project = await projects.requireProject(request.params.projectId);
return await workspaces.list(project);
} catch (error) {
return reply.code(404).send({ error: error instanceof Error ? error.message : String(error) });
}
});
registerLocalProjectRoutes(app, projects, workspaces, "/api");
registerLocalProjectRoutes(app, projects, workspaces, "/api/machines/local");
registerSessionProxyRoutes(app);
registerSessionProxyRoutes(app, undefined, "/api/machines/local");
registerWorkspaceExplorerRoutes(app, projects, workspaces);
registerWorkspaceExplorerRoutes(app, projects, workspaces, "/api/machines/local");
registerGitRoutes(app, projects, workspaces);
registerGitRoutes(app, projects, workspaces, "/api/machines/local");
registerTerminalProxyRoutes(app, projects, workspaces);
registerTerminalProxyRoutes(app, projects, workspaces, undefined, "/api/machines/local");
app.get<{ Querystring: { cwd?: string; q?: string; kind?: "tracked" | "untracked" | "other"; mode?: "file" | "path" } }>("/api/files", async (request, reply) => {
if (request.query.cwd === undefined || request.query.cwd === "") return reply.code(400).send({ error: "cwd query parameter is required" });
try {
if (request.query.mode === "path") return await listPathSuggestions(request.query.cwd, request.query.q ?? "");
return await listFileSuggestions(request.query.cwd, request.query.q ?? "", request.query.kind);
} catch (error) {
return reply.code(400).send({ error: error instanceof Error ? error.message : String(error) });
}
});
registerLocalFileSuggestionRoutes(app, "/api");
registerLocalFileSuggestionRoutes(app, "/api/machines/local");
const packagedClientDist = join(dirname(fileURLToPath(import.meta.url)), "..", "client");
const clientDist = deps.clientDist ?? (existsSync(packagedClientDist) ? packagedClientDist : join(process.cwd(), "dist", "client"));
+3 -3
View File
@@ -4,8 +4,8 @@ import type { WorkspaceService } from "./workspaces/workspaceService.js";
import { resolveWorkspaceContext } from "./workspaces/workspaceContext.js";
import { gitDiff, gitStatus } from "./git/gitService.js";
export function registerGitRoutes(app: FastifyInstance, projects: ProjectService, workspaces: WorkspaceService): void {
app.get<{ Params: { projectId: string; workspaceId: string } }>("/api/projects/:projectId/workspaces/:workspaceId/git/status", async (request, reply) => {
export function registerGitRoutes(app: FastifyInstance, projects: ProjectService, workspaces: WorkspaceService, prefix = "/api"): void {
app.get<{ Params: { projectId: string; workspaceId: string } }>(`${prefix}/projects/:projectId/workspaces/:workspaceId/git/status`, async (request, reply) => {
try {
const context = await resolveWorkspaceContext(projects, workspaces, request.params.projectId, request.params.workspaceId);
return await gitStatus(context.root);
@@ -14,7 +14,7 @@ export function registerGitRoutes(app: FastifyInstance, projects: ProjectService
}
});
app.get<{ Params: { projectId: string; workspaceId: string }; Querystring: { path?: string; staged?: string } }>("/api/projects/:projectId/workspaces/:workspaceId/git/diff", async (request, reply) => {
app.get<{ Params: { projectId: string; workspaceId: string }; Querystring: { path?: string; staged?: string } }>(`${prefix}/projects/:projectId/workspaces/:workspaceId/git/diff`, async (request, reply) => {
try {
const context = await resolveWorkspaceContext(projects, workspaces, request.params.projectId, request.params.workspaceId);
return await gitDiff(context.root, { ...(request.query.path === undefined ? {} : { path: request.query.path }), staged: request.query.staged === "true" });
@@ -0,0 +1,49 @@
import Fastify, { type FastifyInstance } from "fastify";
import fastifyWebsocket from "@fastify/websocket";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { registerSessionProxyRoutes } from "./sessionProxyRoutes";
let app: FastifyInstance;
let daemon: FakeSessionDaemon;
beforeEach(async () => {
app = Fastify({ logger: false });
await app.register(fastifyWebsocket);
daemon = new FakeSessionDaemon();
registerSessionProxyRoutes(app, daemon, "/api/machines/local");
});
afterEach(async () => {
await app.close();
});
describe("machine-scoped session proxy routes", () => {
it("strips the machine prefix before forwarding session requests", async () => {
const response = await app.inject({ method: "GET", url: "/api/machines/local/sessions?cwd=/repo" });
expect(response.statusCode).toBe(200);
expect(response.json()).toEqual({ ok: true });
expect(daemon.requests).toEqual([{ method: "GET", path: "/sessions?cwd=/repo", body: undefined }]);
});
it("strips the machine prefix before forwarding auth requests", async () => {
const response = await app.inject({ method: "POST", url: "/api/machines/local/auth/api-key", payload: { providerId: "p", key: "k" } });
expect(response.statusCode).toBe(200);
expect(response.json()).toEqual({ ok: true });
expect(daemon.requests).toEqual([{ method: "POST", path: "/auth/api-key", body: { providerId: "p", key: "k" } }]);
});
});
class FakeSessionDaemon {
readonly requests: { method: string; path: string; body: unknown }[] = [];
request(method: string, path: string, body?: unknown): Promise<{ statusCode: number; headers: Record<string, string>; body: string }> {
this.requests.push({ method, path, body });
return Promise.resolve({ statusCode: 200, headers: { "content-type": "application/json" }, body: JSON.stringify({ ok: true }) });
}
connectWebSocket(): never {
throw new Error("not implemented");
}
}
+20 -13
View File
@@ -2,10 +2,15 @@ import type { FastifyInstance, FastifyReply } from "fastify";
import { WebSocket, type RawData } from "ws";
import { SessionDaemonClient } from "./sessionDaemonClient.js";
export function registerSessionProxyRoutes(app: FastifyInstance, daemon = new SessionDaemonClient()): void {
export interface SessionProxyDaemon {
request(method: string, path: string, body?: unknown): Promise<{ statusCode: number; headers: Record<string, string>; body: string }>;
connectWebSocket(path: string): WebSocket;
}
export function registerSessionProxyRoutes(app: FastifyInstance, daemon: SessionProxyDaemon = new SessionDaemonClient(), prefix = "/api"): void {
const proxy = async (request: { method: string; url: string; body?: unknown }, reply: FastifyReply) => {
try {
const upstream = await daemon.request(request.method, stripApiPrefix(request.url), request.body);
const upstream = await daemon.request(request.method, stripPrefix(request.url, prefix), request.body);
reply.code(upstream.statusCode);
const contentType = upstream.headers["content-type"];
if (contentType !== undefined && contentType !== "") reply.header("content-type", contentType);
@@ -16,29 +21,31 @@ export function registerSessionProxyRoutes(app: FastifyInstance, daemon = new Se
}
};
app.get("/api/sessiond/health", (_request, reply) => proxy({ method: "GET", url: "/api/health" }, reply));
app.get(`${prefix}/sessiond/health`, (_request, reply) => proxy({ method: "GET", url: `${prefix}/health` }, reply));
app.get<{ Params: { sessionId: string } }>("/api/sessions/:sessionId/events", { websocket: true }, (socket, request) => {
app.get<{ Params: { sessionId: string } }>(`${prefix}/sessions/:sessionId/events`, { websocket: true }, (socket, request) => {
bridgeSockets(socket, daemon.connectWebSocket(`/sessions/${request.params.sessionId}/events`));
});
app.get("/api/sessions/events", { websocket: true }, (socket) => {
app.get(`${prefix}/sessions/events`, { websocket: true }, (socket) => {
bridgeSockets(socket, daemon.connectWebSocket("/sessions/events"));
});
app.get("/api/events", { websocket: true }, (socket) => {
app.get(`${prefix}/events`, { websocket: true }, (socket) => {
bridgeSockets(socket, daemon.connectWebSocket("/events"));
});
app.all("/api/activity", (request, reply) => proxy(request, reply));
app.all("/api/auth", (request, reply) => proxy(request, reply));
app.all("/api/auth/*", (request, reply) => proxy(request, reply));
app.all("/api/sessions", (request, reply) => proxy(request, reply));
app.all("/api/sessions/*", (request, reply) => proxy(request, reply));
app.all(`${prefix}/activity`, (request, reply) => proxy(request, reply));
app.all(`${prefix}/auth`, (request, reply) => proxy(request, reply));
app.all(`${prefix}/auth/*`, (request, reply) => proxy(request, reply));
app.all(`${prefix}/sessions`, (request, reply) => proxy(request, reply));
app.all(`${prefix}/sessions/*`, (request, reply) => proxy(request, reply));
}
function stripApiPrefix(url: string): string {
const stripped = url.startsWith("/api") ? url.slice(4) : url;
function stripPrefix(url: string, prefix: string): string {
const path = url.split("?", 1)[0] ?? url;
const query = url.slice(path.length);
const stripped = path.startsWith(prefix) ? `${path.slice(prefix.length)}${query}` : url;
return stripped === "" ? "/" : stripped;
}
+10 -10
View File
@@ -6,8 +6,8 @@ import type { WorkspaceService } from "./workspaces/workspaceService.js";
import { terminalSizeQuery } from "./terminals/terminalSize.js";
import { bridgeSockets } from "./webSocketBridge.js";
export function registerTerminalProxyRoutes(app: FastifyInstance, projects: ProjectService, workspaces: WorkspaceService, daemon = new SessionDaemonClient()): void {
app.get<{ Params: { projectId: string; workspaceId: string } }>("/api/projects/:projectId/workspaces/:workspaceId/terminals", async (request, reply) => {
export function registerTerminalProxyRoutes(app: FastifyInstance, projects: ProjectService, workspaces: WorkspaceService, daemon = new SessionDaemonClient(), prefix = "/api"): void {
app.get<{ Params: { projectId: string; workspaceId: string } }>(`${prefix}/projects/:projectId/workspaces/:workspaceId/terminals`, async (request, reply) => {
try {
const context = await resolveWorkspaceContext(projects, workspaces, request.params.projectId, request.params.workspaceId);
return await proxyJson(daemon, "GET", `/terminals?cwd=${encodeURIComponent(context.root)}`, undefined, reply);
@@ -17,7 +17,7 @@ export function registerTerminalProxyRoutes(app: FastifyInstance, projects: Proj
}
});
app.post<{ Params: { projectId: string; workspaceId: string }; Body: { name?: string; cols?: number; rows?: number } }>("/api/projects/:projectId/workspaces/:workspaceId/terminals", async (request, reply) => {
app.post<{ Params: { projectId: string; workspaceId: string }; Body: { name?: string; cols?: number; rows?: number } }>(`${prefix}/projects/:projectId/workspaces/:workspaceId/terminals`, async (request, reply) => {
try {
const context = await resolveWorkspaceContext(projects, workspaces, request.params.projectId, request.params.workspaceId);
return await proxyJson(daemon, "POST", "/terminals", { ...request.body, cwd: context.root }, reply);
@@ -27,7 +27,7 @@ export function registerTerminalProxyRoutes(app: FastifyInstance, projects: Proj
}
});
app.post<{ Params: { projectId: string; workspaceId: string; terminalId: string } }>("/api/projects/:projectId/workspaces/:workspaceId/terminals/:terminalId/continue", async (request, reply) => {
app.post<{ Params: { projectId: string; workspaceId: string; terminalId: string } }>(`${prefix}/projects/:projectId/workspaces/:workspaceId/terminals/:terminalId/continue`, async (request, reply) => {
try {
await resolveWorkspaceContext(projects, workspaces, request.params.projectId, request.params.workspaceId);
return await proxyJson(daemon, "POST", `/terminals/${encodeURIComponent(request.params.terminalId)}/continue`, undefined, reply);
@@ -37,7 +37,7 @@ export function registerTerminalProxyRoutes(app: FastifyInstance, projects: Proj
}
});
app.delete<{ Params: { projectId: string; workspaceId: string; terminalId: string } }>("/api/projects/:projectId/workspaces/:workspaceId/terminals/:terminalId", async (request, reply) => {
app.delete<{ Params: { projectId: string; workspaceId: string; terminalId: string } }>(`${prefix}/projects/:projectId/workspaces/:workspaceId/terminals/:terminalId`, async (request, reply) => {
try {
await resolveWorkspaceContext(projects, workspaces, request.params.projectId, request.params.workspaceId);
return await proxyJson(daemon, "DELETE", `/terminals/${encodeURIComponent(request.params.terminalId)}`, undefined, reply);
@@ -47,7 +47,7 @@ export function registerTerminalProxyRoutes(app: FastifyInstance, projects: Proj
}
});
app.post<{ Params: { projectId: string; workspaceId: string }; Body: TerminalCommandRunRequest }>("/api/projects/:projectId/workspaces/:workspaceId/terminal-command-runs", async (request, reply) => {
app.post<{ Params: { projectId: string; workspaceId: string }; Body: TerminalCommandRunRequest }>(`${prefix}/projects/:projectId/workspaces/:workspaceId/terminal-command-runs`, async (request, reply) => {
try {
const context = await resolveWorkspaceContext(projects, workspaces, request.params.projectId, request.params.workspaceId);
return await proxyJson(daemon, "POST", "/terminal-command-runs", {
@@ -65,7 +65,7 @@ export function registerTerminalProxyRoutes(app: FastifyInstance, projects: Proj
}
});
app.get<{ Querystring: TerminalCommandRunQuery }>("/api/terminal-command-runs", async (request, reply) => {
app.get<{ Querystring: TerminalCommandRunQuery }>(`${prefix}/terminal-command-runs`, async (request, reply) => {
try {
return await proxyJson(daemon, "GET", `/terminal-command-runs${terminalCommandRunQuery(request.query)}`, undefined, reply);
} catch (error) {
@@ -74,7 +74,7 @@ export function registerTerminalProxyRoutes(app: FastifyInstance, projects: Proj
}
});
app.post<{ Params: { runId: string } }>("/api/terminal-command-runs/:runId/cancel", async (request, reply) => {
app.post<{ Params: { runId: string } }>(`${prefix}/terminal-command-runs/:runId/cancel`, async (request, reply) => {
try {
return await proxyJson(daemon, "POST", `/terminal-command-runs/${encodeURIComponent(request.params.runId)}/cancel`, undefined, reply);
} catch (error) {
@@ -83,7 +83,7 @@ export function registerTerminalProxyRoutes(app: FastifyInstance, projects: Proj
}
});
app.get<{ Params: { runId: string } }>("/api/terminal-command-runs/:runId", async (request, reply) => {
app.get<{ Params: { runId: string } }>(`${prefix}/terminal-command-runs/:runId`, async (request, reply) => {
try {
return await proxyJson(daemon, "GET", `/terminal-command-runs/${encodeURIComponent(request.params.runId)}`, undefined, reply);
} catch (error) {
@@ -92,7 +92,7 @@ export function registerTerminalProxyRoutes(app: FastifyInstance, projects: Proj
}
});
app.get<{ Params: { projectId: string; workspaceId: string; terminalId: string }; Querystring: { cols?: string; rows?: string } }>("/api/projects/:projectId/workspaces/:workspaceId/terminals/:terminalId/socket", { websocket: true }, async (socket, request) => {
app.get<{ Params: { projectId: string; workspaceId: string; terminalId: string }; Querystring: { cols?: string; rows?: string } }>(`${prefix}/projects/:projectId/workspaces/:workspaceId/terminals/:terminalId/socket`, { websocket: true }, async (socket, request) => {
try {
await resolveWorkspaceContext(projects, workspaces, request.params.projectId, request.params.workspaceId);
const sizeQuery = terminalSizeQuery(request.query.cols, request.query.rows);
+4 -4
View File
@@ -6,8 +6,8 @@ import { listWorkspaceTree } from "./workspaces/fileTreeService.js";
import { readWorkspaceFile } from "./workspaces/fileContentService.js";
import { readWorkspaceImagePreview } from "./workspaces/imagePreviewService.js";
export function registerWorkspaceExplorerRoutes(app: FastifyInstance, projects: ProjectService, workspaces: WorkspaceService): void {
app.get<{ Params: { projectId: string; workspaceId: string }; Querystring: { path?: string } }>("/api/projects/:projectId/workspaces/:workspaceId/tree", async (request, reply) => {
export function registerWorkspaceExplorerRoutes(app: FastifyInstance, projects: ProjectService, workspaces: WorkspaceService, prefix = "/api"): void {
app.get<{ Params: { projectId: string; workspaceId: string }; Querystring: { path?: string } }>(`${prefix}/projects/:projectId/workspaces/:workspaceId/tree`, async (request, reply) => {
try {
const context = await resolveWorkspaceContext(projects, workspaces, request.params.projectId, request.params.workspaceId);
return await listWorkspaceTree(context.root, request.query.path);
@@ -16,7 +16,7 @@ export function registerWorkspaceExplorerRoutes(app: FastifyInstance, projects:
}
});
app.get<{ Params: { projectId: string; workspaceId: string }; Querystring: { path?: string } }>("/api/projects/:projectId/workspaces/:workspaceId/file", async (request, reply) => {
app.get<{ Params: { projectId: string; workspaceId: string }; Querystring: { path?: string } }>(`${prefix}/projects/:projectId/workspaces/:workspaceId/file`, async (request, reply) => {
try {
const context = await resolveWorkspaceContext(projects, workspaces, request.params.projectId, request.params.workspaceId);
return await readWorkspaceFile(context.root, request.query.path);
@@ -25,7 +25,7 @@ export function registerWorkspaceExplorerRoutes(app: FastifyInstance, projects:
}
});
app.get<{ Params: { projectId: string; workspaceId: string }; Querystring: { path?: string } }>("/api/projects/:projectId/workspaces/:workspaceId/file/preview", async (request, reply) => {
app.get<{ Params: { projectId: string; workspaceId: string }; Querystring: { path?: string } }>(`${prefix}/projects/:projectId/workspaces/:workspaceId/file/preview`, async (request, reply) => {
try {
const context = await resolveWorkspaceContext(projects, workspaces, request.params.projectId, request.params.workspaceId);
const preview = await readWorkspaceImagePreview(context.root, request.query.path);