diff --git a/configs/webpack/webpack.config.renderer.dev.ts b/configs/webpack/webpack.config.renderer.dev.ts index 82d5857bb..79cead2ce 100644 --- a/configs/webpack/webpack.config.renderer.dev.ts +++ b/configs/webpack/webpack.config.renderer.dev.ts @@ -227,6 +227,26 @@ const configuration: webpack.Configuration = { headers: { 'Access-Control-Allow-Origin': '*' }, static: { publicPath: '/' }, historyApiFallback: { verbose: true }, + client: { + overlay: { + // Monaco cancels pending work by rejecting with an error it names + // `Canceled` — every disposed editor leaves one behind for whichever + // debounced contribution was still armed (see + // `frontend/utils/ignore-monaco-cancellations.ts`). The runtime guard + // there calls `preventDefault`, which silences the console but cannot + // silence this overlay: the dev-server client registers its own + // listener when the bundle boots, so it always runs first and + // `preventDefault` does not stop it. The result is a full-screen + // overlay over a cancellation that was deliberate, and since the + // overlay sits above everything it swallows every click until + // dismissed — reloading a project from the source-control panel used + // to leave the app looking frozen. + // + // This function is serialized into the client bundle, so it must not + // reference anything outside itself. + runtimeErrors: (error?: Error) => !(error instanceof Error && error.name === 'Canceled'), + }, + }, }, } diff --git a/src/backend/editor/contracts/validations/types.ts b/src/backend/editor/contracts/validations/types.ts index 8b0d7c3d1..1464520aa 100644 --- a/src/backend/editor/contracts/validations/types.ts +++ b/src/backend/editor/contracts/validations/types.ts @@ -18,6 +18,20 @@ const StoreSchema = z.object({ y: z.number(), }), }), + /** + * The Edge session, when the user has chosen to sign in. Optional because signing + * in is optional: the editor is fully usable with no account, and an absent key is + * the normal state rather than a missing value to repair. + * + * `refreshToken` holds a base64 `safeStorage` ciphertext, never the raw token. See + * `backend/editor/edge-account/session-store.ts` for why the access token is + * deliberately not kept. + */ + edge_session: z + .object({ + refreshToken: z.string(), + }) + .optional(), }) export { StoreSchema, ThemeSchema } diff --git a/src/backend/editor/edge-account/__tests__/edge-account-service.test.ts b/src/backend/editor/edge-account/__tests__/edge-account-service.test.ts new file mode 100644 index 000000000..435f3280d --- /dev/null +++ b/src/backend/editor/edge-account/__tests__/edge-account-service.test.ts @@ -0,0 +1,336 @@ +/** + * The session logic, with HTTP and disk stubbed out. + * + * What is worth protecting is not the request shapes — one line each — but four + * decisions that are easy to regress and expensive when they break: + * + * - an unverified email arrives as a 200 with a null access token. Read as a failure, + * it sends someone with the right password hunting for a wrong one. + * - a transport failure must surface as `unknown`, never as `no-session`. + * - rotation is single-use, so concurrent renewals must collapse onto ONE request. + * - a refused renewal must drop the stored token, or every launch afterwards begins + * with a request that can only fail. + */ + +import { + __resetInMemorySessionForTests, + adoptProviderTokens, + fetchPlanCaption, + fetchUser, + signIn, + signOut, +} from '../edge-account-service' +import { edgeRequest } from '../edge-http' +import { clearRefreshToken, readRefreshToken, saveRefreshToken } from '../session-store' + +jest.mock('../edge-http', () => ({ + edgeRequest: jest.fn(), + parseJsonBody: (body: string) => { + try { + return JSON.parse(body) + } catch { + return null + } + }, +})) + +jest.mock('../session-store', () => ({ + saveRefreshToken: jest.fn(() => ({ persisted: true })), + readRefreshToken: jest.fn(), + clearRefreshToken: jest.fn(), + isEncryptionAvailable: jest.fn(() => true), +})) + +const request = edgeRequest as jest.MockedFunction +const readStored = readRefreshToken as jest.MockedFunction +const saveStored = saveRefreshToken as jest.MockedFunction +const clearStored = clearRefreshToken as jest.MockedFunction + +const USER = { id: 'u1', name: 'Ada', email: 'ada@example.com', username: 'ada' } + +/** A JWT whose only meaningful claim is an `exp` the given distance from now. */ +function tokenExpiringIn(ms: number): string { + const payload = Buffer.from(JSON.stringify({ exp: Math.floor((Date.now() + ms) / 1000) })).toString('base64url') + + return `header.${payload}.signature` +} + +const LIVE_TOKEN = tokenExpiringIn(7 * 24 * 60 * 60 * 1000) + +function ok(data: unknown) { + return { status: 200, body: JSON.stringify({ data }) } +} + +beforeEach(() => { + jest.clearAllMocks() + __resetInMemorySessionForTests() + readStored.mockReturnValue(null) +}) + +describe('signIn', () => { + it('maps a 401 to invalid credentials', async () => { + request.mockResolvedValueOnce({ status: 401, body: '{}' }) + + await expect(signIn('ada@example.com', 'wrong')).resolves.toEqual({ status: 'invalid-credentials' }) + expect(saveStored).not.toHaveBeenCalled() + }) + + it('reads a 200 with a null access token as an unverified address', async () => { + // Edge answers exactly this for a correct password on an unverified account. + request.mockResolvedValueOnce(ok({ accessToken: null, refreshToken: null, user: USER })) + + await expect(signIn('ada@example.com', 'right')).resolves.toEqual({ + status: 'email-unverified', + email: 'ada@example.com', + }) + }) + + it('adopts the pair and persists only the refresh token', async () => { + request.mockResolvedValueOnce(ok({ accessToken: LIVE_TOKEN, refreshToken: 'r1', user: USER })) + + await expect(signIn('ada@example.com', 'right')).resolves.toEqual({ status: 'signed-in', user: USER }) + + // The access token is deliberately never written down — it lives 7 days. + expect(saveStored).toHaveBeenCalledTimes(1) + expect(saveStored).toHaveBeenCalledWith('r1') + }) + + it('names the user with a follow-up read when the response omits one', async () => { + request + .mockResolvedValueOnce(ok({ accessToken: LIVE_TOKEN, refreshToken: 'r1' })) + .mockResolvedValueOnce(ok({ user: USER })) + + await expect(signIn('ada@example.com', 'right')).resolves.toEqual({ status: 'signed-in', user: USER }) + }) + + it('fails, holding no half session, when the user cannot be named', async () => { + request.mockResolvedValueOnce(ok({ accessToken: LIVE_TOKEN, refreshToken: 'r1' })).mockResolvedValueOnce(ok({})) + + await expect(signIn('ada@example.com', 'right')).resolves.toEqual({ status: 'failed' }) + expect(clearStored).toHaveBeenCalled() + }) + + it('fails on an access token with no refresh token', async () => { + // Not `email-unverified` — that case is a NULL access token. Here there is a usable + // access token and nothing to renew it with, which is a session that dies in 7 days + // with no way back. + request.mockResolvedValueOnce(ok({ accessToken: LIVE_TOKEN, refreshToken: null, user: USER })) + + await expect(signIn('ada@example.com', 'right')).resolves.toEqual({ status: 'failed' }) + }) + + it('reports a transport failure as a failed sign-in', async () => { + request.mockRejectedValueOnce(new Error('ECONNREFUSED')) + + await expect(signIn('ada@example.com', 'right')).resolves.toEqual({ status: 'failed' }) + }) + + it('maps a 500 to a failed sign-in', async () => { + request.mockResolvedValueOnce({ status: 500, body: 'upstream exploded' }) + + await expect(signIn('ada@example.com', 'right')).resolves.toEqual({ status: 'failed' }) + }) +}) + +describe('adoptProviderTokens', () => { + it('adopts a harvested pair and names the user', async () => { + request.mockResolvedValueOnce(ok({ user: USER })) + + await expect(adoptProviderTokens({ accessToken: LIVE_TOKEN, refreshToken: 'r1' })).resolves.toEqual({ + status: 'signed-in', + user: USER, + }) + expect(saveStored).toHaveBeenCalledWith('r1') + }) + + it('fails on an incomplete pair without touching storage', async () => { + await expect(adoptProviderTokens({ accessToken: LIVE_TOKEN })).resolves.toEqual({ status: 'failed' }) + expect(saveStored).not.toHaveBeenCalled() + }) +}) + +describe('fetchUser', () => { + it('says no-session when there is nothing to renew with', async () => { + await expect(fetchUser()).resolves.toEqual({ status: 'no-session' }) + expect(request).not.toHaveBeenCalled() + }) + + it('renews from the stored token, then answers', async () => { + readStored.mockReturnValue('stored-r') + request + .mockResolvedValueOnce(ok({ accessToken: LIVE_TOKEN, refreshToken: 'r2' })) + .mockResolvedValueOnce(ok({ user: USER })) + + await expect(fetchUser()).resolves.toEqual({ status: 'signed-in', user: USER }) + + // Restoring a session across restarts needs no separate step: the first read + // renews from disk on its own. + expect(request).toHaveBeenNthCalledWith(1, '/auth/refresh', { method: 'POST', json: { refreshToken: 'stored-r' } }) + expect(saveStored).toHaveBeenCalledWith('r2') + }) + + it('renews once and retries when a live-looking token is refused', async () => { + readStored.mockReturnValue('stored-r') + request + .mockResolvedValueOnce(ok({ accessToken: LIVE_TOKEN, refreshToken: 'r2' })) + // Refused despite a future `exp`: revoked from another device, or the account's + // tokens invalidated by a password change. + .mockResolvedValueOnce({ status: 401, body: '{}' }) + .mockResolvedValueOnce(ok({ accessToken: LIVE_TOKEN, refreshToken: 'r3' })) + .mockResolvedValueOnce(ok({ user: USER })) + + await expect(fetchUser()).resolves.toEqual({ status: 'signed-in', user: USER }) + expect(request).toHaveBeenCalledTimes(4) + }) + + it('gives up after one forced renewal that fails', async () => { + readStored.mockReturnValue('stored-r') + request + .mockResolvedValueOnce(ok({ accessToken: LIVE_TOKEN, refreshToken: 'r2' })) + .mockResolvedValueOnce({ status: 401, body: '{}' }) + .mockResolvedValueOnce({ status: 401, body: '{}' }) + + await expect(fetchUser()).resolves.toEqual({ status: 'no-session' }) + }) + + it('surfaces a transport failure as unknown, never as no-session', async () => { + readStored.mockReturnValue('stored-r') + request.mockRejectedValueOnce(new Error('offline')) + + await expect(fetchUser()).resolves.toEqual({ status: 'unknown' }) + }) + + it('says no-session when the renewal is refused, and drops the dead token', async () => { + readStored.mockReturnValue('revoked-r') + request.mockResolvedValueOnce({ status: 401, body: '{}' }) + + await expect(fetchUser()).resolves.toEqual({ status: 'no-session' }) + expect(clearStored).toHaveBeenCalledTimes(1) + }) + + it('keeps the token when the renewal fails with a 5xx', async () => { + readStored.mockReturnValue('stored-r') + request.mockResolvedValueOnce({ status: 503, body: '' }) + + await expect(fetchUser()).resolves.toEqual({ status: 'no-session' }) + // A 5xx says nothing about whether the token is valid. + expect(clearStored).not.toHaveBeenCalled() + }) + + it('says no-session when the profile payload carries no user', async () => { + readStored.mockReturnValue('stored-r') + request.mockResolvedValueOnce(ok({ accessToken: LIVE_TOKEN, refreshToken: 'r2' })).mockResolvedValueOnce(ok({})) + + await expect(fetchUser()).resolves.toEqual({ status: 'no-session' }) + }) + + it('collapses concurrent renewals onto one request', async () => { + readStored.mockReturnValue('stored-r') + + let release: (value: { status: number; body: string }) => void = () => undefined + const pending = new Promise<{ status: number; body: string }>((resolve) => { + release = resolve + }) + + request.mockReturnValueOnce(pending).mockResolvedValue(ok({ user: USER })) + + const both = Promise.all([fetchUser(), fetchUser()]) + release(ok({ accessToken: LIVE_TOKEN, refreshToken: 'r2' })) + + await expect(both).resolves.toEqual([ + { status: 'signed-in', user: USER }, + { status: 'signed-in', user: USER }, + ]) + + // Refresh tokens are single-use: a second renewal would present a superseded token + // and lean on the server's replay window to recover. + expect(request.mock.calls.filter(([path]) => path === '/auth/refresh')).toHaveLength(1) + }) + + it('renews a token that is inside the expiry margin', async () => { + readStored.mockReturnValue('stored-r') + request + .mockResolvedValueOnce(ok({ accessToken: tokenExpiringIn(5_000), refreshToken: 'r1', user: USER })) + .mockResolvedValueOnce(ok({ accessToken: LIVE_TOKEN, refreshToken: 'r2' })) + .mockResolvedValueOnce(ok({ user: USER })) + + await signIn('ada@example.com', 'right') + + await expect(fetchUser()).resolves.toEqual({ status: 'signed-in', user: USER }) + expect(request.mock.calls.filter(([path]) => path === '/auth/refresh')).toHaveLength(1) + }) + + it('treats an unreadable token as needing renewal rather than trusting it', async () => { + readStored.mockReturnValue('stored-r') + request + .mockResolvedValueOnce(ok({ accessToken: 'not-a-jwt', refreshToken: 'r1', user: USER })) + .mockResolvedValueOnce(ok({ accessToken: LIVE_TOKEN, refreshToken: 'r2' })) + .mockResolvedValueOnce(ok({ user: USER })) + + await signIn('ada@example.com', 'right') + + await expect(fetchUser()).resolves.toEqual({ status: 'signed-in', user: USER }) + }) +}) + +describe('fetchPlanCaption', () => { + it('renders the plan name the way Edge does', async () => { + readStored.mockReturnValue('stored-r') + request + .mockResolvedValueOnce(ok({ accessToken: LIVE_TOKEN, refreshToken: 'r2' })) + .mockResolvedValueOnce(ok({ plan: { displayName: 'Pro' } })) + + await expect(fetchPlanCaption()).resolves.toBe('Pro Plan') + }) + + it('returns null for an account with no plan', async () => { + readStored.mockReturnValue('stored-r') + request + .mockResolvedValueOnce(ok({ accessToken: LIVE_TOKEN, refreshToken: 'r2' })) + // Edge answers 404 for Community, expired or cancelled — a valid state, not an + // error. + .mockResolvedValueOnce({ status: 404, body: '{}' }) + + await expect(fetchPlanCaption()).resolves.toBeNull() + }) + + it('returns null rather than propagating a transport failure', async () => { + readStored.mockReturnValue('stored-r') + request.mockRejectedValueOnce(new Error('offline')) + + await expect(fetchPlanCaption()).resolves.toBeNull() + }) + + it('returns null when there is no session at all', async () => { + await expect(fetchPlanCaption()).resolves.toBeNull() + }) +}) + +describe('signOut', () => { + it('revokes server-side and clears locally', async () => { + readStored.mockReturnValue('stored-r') + request.mockResolvedValueOnce({ status: 200, body: '{}' }) + + await signOut() + + expect(clearStored).toHaveBeenCalledTimes(1) + expect(request).toHaveBeenCalledWith('/auth/logout', { method: 'POST', json: { refreshToken: 'stored-r' } }) + }) + + it('clears locally even when the request fails', async () => { + readStored.mockReturnValue('stored-r') + request.mockRejectedValueOnce(new Error('offline')) + + await expect(signOut()).resolves.toBeUndefined() + + // Someone who asked to leave must end up signed out; the server-side token expires + // on its own. + expect(clearStored).toHaveBeenCalledTimes(1) + }) + + it('skips the request when there is nothing to revoke', async () => { + await signOut() + + expect(request).not.toHaveBeenCalled() + }) +}) diff --git a/src/backend/editor/edge-account/__tests__/oauth-window.test.ts b/src/backend/editor/edge-account/__tests__/oauth-window.test.ts new file mode 100644 index 000000000..98201a240 --- /dev/null +++ b/src/backend/editor/edge-account/__tests__/oauth-window.test.ts @@ -0,0 +1,67 @@ +/** + * Only the URL matcher is exercised here. Running an actual flow needs a real + * `BrowserWindow` and a real provider, which is an end-to-end concern. + * + * The matcher earns its own tests because it decides between two very different fates + * for a link: intercepted into a window this process owns, or handed to the system + * browser. Wrong in one direction and provider tokens land in a jar we cannot read — + * which is exactly the bug this replaced, where the click merely opened Edge in a + * browser and nothing came back. Wrong in the other and ordinary links (the docs, the + * licence buy page) get swallowed into a login window. + */ + +/** + * `oauth-window` imports `electron` at module scope, and CI installs with + * `--ignore-scripts` — so Electron's postinstall never runs, the binary path + * file is absent, and `require('electron')` throws before a single test can + * start. Stubbing what the module reaches for keeps the matcher testable + * without a real Electron. Same shape as `utils/__tests__/path-picker.test.ts`. + */ +jest.mock('electron', () => ({ + BrowserWindow: class {}, + session: { fromPartition: () => ({}) }, +})) + +import { edgeOAuthProviderFromUrl } from '../oauth-window' + +describe('edgeOAuthProviderFromUrl', () => { + it.each([ + ['https://api.autonomylogic.com/auth/google?state=editor', 'google'], + ['https://api.autonomylogic.com/auth/microsoft', 'microsoft'], + ['https://api.autonomylogic.com/auth/apple', 'apple'], + ])('recognises %s', (url, expected) => { + expect(edgeOAuthProviderFromUrl(url)).toBe(expected) + }) + + it('matches on path regardless of origin', () => { + // Load-bearing: the shared dialog builds its links from the Edge WEB origin, because + // that is the only Edge URL a renderer bundle knows, while the real endpoint is on + // the API origin that only the main process is configured with. Matching on origin + // would force the two to agree about something only one of them can know. + expect(edgeOAuthProviderFromUrl('https://edge.autonomylogic.com/auth/google?state=x')).toBe('google') + expect(edgeOAuthProviderFromUrl('http://localhost:5173/auth/apple')).toBe('apple') + }) + + it('tolerates a trailing slash', () => { + expect(edgeOAuthProviderFromUrl('https://api.autonomylogic.com/auth/google/')).toBe('google') + }) + + it.each([ + // Edge's own sign-in page, not a provider endpoint. + 'https://edge.autonomylogic.com/signin', + // A provider name in the wrong position. + 'https://edge.autonomylogic.com/projects/auth/google/extra', + // These must keep going to the system browser. + 'https://autonomylogic.com/docs', + 'https://edge.autonomylogic.com/buy', + // A provider Edge does not offer must not be intercepted on the strength of the + // prefix. + 'https://api.autonomylogic.com/auth/facebook', + // Casing is not normalised: the value would go into a route path verbatim. + 'https://api.autonomylogic.com/auth/Google', + 'not a url', + '', + ])('leaves %s to the system browser', (url) => { + expect(edgeOAuthProviderFromUrl(url)).toBeNull() + }) +}) diff --git a/src/backend/editor/edge-account/edge-account-service.ts b/src/backend/editor/edge-account/edge-account-service.ts new file mode 100644 index 000000000..e0a104a47 --- /dev/null +++ b/src/backend/editor/edge-account/edge-account-service.ts @@ -0,0 +1,353 @@ +/** + * The desktop editor's Edge session. + * + * WHY THE DESKTOP NEEDS ITS OWN. The openplc-web editor authenticates purely by the + * `httpOnly` cookie Edge leaves on a shared parent domain, and never handles a token + * itself. The desktop renderer is not on that domain, so there is no cookie to + * inherit: it has to hold the session. That single fact is why this flow is + * token-based while the web one is cookie-based, against the same API. + * + * WHAT IS HELD WHERE. The refresh token is the durable half, persisted encrypted (see + * `session-store`). The access token is in memory only. + * + * SIGNING IN IS OPTIONAL. Nothing here runs unless the user asks. A session that + * cannot be restored is not an error: it is the ordinary condition of an editor being + * used offline, on a local project, by someone who never wanted an account. + */ + +import type { EdgeSignInOutcome, EdgeUser, EdgeUserRead } from '../../../middleware/shared/ports/edge-account-port' +import { edgeRequest, parseJsonBody } from './edge-http' +import { clearRefreshToken, readRefreshToken, saveRefreshToken } from './session-store' + +/** In-memory access token and the moment it stops being usable. */ +let accessToken: string | null = null +let accessTokenExpiresAtMs = 0 + +/** + * The one renewal allowed to be in flight. + * + * Refresh tokens are single-use and rotate, so two concurrent renewals with the same + * token race: one rotates and the other presents a superseded value. Edge has a + * 60-second replay window that makes the loser recover rather than fail, but leaning + * on it would still mean two round trips and two rotations to serve one need. + */ +let renewal: Promise | null = null + +/** + * Renew this far before the token actually dies. Anything tighter turns clock skew + * between this machine and the server into intermittent 401s. + */ +const RENEW_MARGIN_MS = 60_000 + +interface TokenPair { + accessToken?: string | null + refreshToken?: string | null +} + +/** Every successful payload from the API arrives wrapped as `{ data: ... }`. */ +interface Envelope { + data?: T +} + +/** + * Adopt a freshly issued pair. + * + * Persisting here rather than at each call site is what keeps rotation honest: the + * moment the server hands out a successor the old value is dead, and a stored token + * one rotation behind means the next launch starts with a request that cannot work. + */ +function adoptTokens(pair: TokenPair): boolean { + if (!pair.accessToken || !pair.refreshToken) { + return false + } + + accessToken = pair.accessToken + accessTokenExpiresAtMs = readJwtExpiryMs(pair.accessToken) + saveRefreshToken(pair.refreshToken) + + return true +} + +/** + * When a JWT says it expires, in epoch milliseconds. + * + * Read from the token rather than assumed from a constant: the lifetime is the + * server's decision and it has already changed once (24h to 7d, EDGE-602). Falling + * back to "now" on an unreadable token is the safe direction — it forces a renewal on + * first use instead of trusting an expiry we could not read. + */ +function readJwtExpiryMs(token: string): number { + try { + const payload = token.split('.')[1] + + if (!payload) { + return Date.now() + } + + const decoded = JSON.parse(Buffer.from(payload, 'base64url').toString('utf-8')) as { exp?: number } + + return typeof decoded.exp === 'number' ? decoded.exp * 1000 : Date.now() + } catch { + return Date.now() + } +} + +/** Drop every local trace of the session. */ +function forgetSession(): void { + accessToken = null + accessTokenExpiresAtMs = 0 + clearRefreshToken() +} + +/** + * Exchange the stored refresh token for a new pair. + * + * Resolves false when there is nothing to renew with or the server refused. In the + * refusal case the stored token is dropped, so the next launch does not repeat a + * request that can only fail. + * + * REJECTS on a transport failure, deliberately. Offline is not signed out, and a + * caller that cannot tell them apart will prompt someone whose session is fine. + */ +async function renewNow(): Promise { + const stored = readRefreshToken() + + if (!stored) { + return false + } + + const response = await edgeRequest('/auth/refresh', { method: 'POST', json: { refreshToken: stored } }) + + if (response.status === 401 || response.status === 403) { + // The server has an opinion and it is no: revoked, expired, or replayed past the + // grace window. + forgetSession() + + return false + } + + if (response.status < 200 || response.status >= 300) { + // A 5xx says nothing about whether the token is valid, so keep it. + return false + } + + return adoptTokens(parseJsonBody>(response.body)?.data ?? {}) +} + +/** Renew, sharing one in-flight attempt across every concurrent caller. */ +function renew(): Promise { + renewal ??= renewNow().finally(() => { + renewal = null + }) + + return renewal +} + +/** A usable access token, renewing when the held one is missing or close to expiry. */ +async function usableAccessToken(): Promise { + if (accessToken && Date.now() < accessTokenExpiresAtMs - RENEW_MARGIN_MS) { + return accessToken + } + + return (await renew()) ? accessToken : null +} + +/** + * A request that carries the session, renewing once if the token turns out to be dead + * despite looking alive. + * + * The retry exists because `usableAccessToken` can only reason about expiry. It cannot + * know the session was revoked from another device, or that the account's tokens were + * invalidated by a password change — both of which arrive as a 401 on a token whose + * `exp` is comfortably in the future. + * + * Exported because the cloud-project layer needs exactly this treatment. Renewal, the + * single-flight guard and the one retry belong here rather than being reimplemented per + * caller. Resolves null when no session could be obtained, and REJECTS on a transport + * failure — callers have to keep "denied" apart from "unreachable". + */ +export async function edgeAuthedRequest( + path: string, + init: { + method?: 'GET' | 'POST' | 'DELETE' + json?: unknown + raw?: { body: Buffer; contentType: string } + timeoutMs?: number + } = {}, +): Promise<{ status: number; body: string } | null> { + const token = await usableAccessToken() + + if (!token) { + return null + } + + const first = await edgeRequest(path, { ...init, accessToken: token }) + + if (first.status !== 401) { + return first + } + + if (!(await renew()) || !accessToken) { + return null + } + + return edgeRequest(path, { ...init, accessToken }) +} + +// --------------------------------------------------------------------------- +// Public surface — one function per IPC handler +// --------------------------------------------------------------------------- + +/** + * Who is signed in. + * + * The three outcomes are not interchangeable, and that is the whole reason + * `EdgeUserRead` exists: `unknown` means the question could not be asked, and a + * caller that reads it as `no-session` prompts over a live session on every blip. + */ +export async function fetchUser(): Promise { + try { + const response = await edgeAuthedRequest('/auth/me') + + if (!response || response.status < 200 || response.status >= 300) { + return { status: 'no-session' } + } + + const user = parseJsonBody>(response.body)?.data?.user + + return user ? { status: 'signed-in', user } : { status: 'no-session' } + } catch { + // Never reached the server, so nothing was established either way. + return { status: 'unknown' } + } +} + +/** + * The caption under the account name, e.g. `Pro Plan`. + * + * Null covers every non-answer: no plan (Edge answers 404 for Community, expired or + * cancelled), no session, or a failed request. A caption is decoration beside a name + * and must never take the menu down with it. + */ +export async function fetchPlanCaption(): Promise { + try { + const response = await edgeAuthedRequest('/me/subscription') + + if (!response || response.status < 200 || response.status >= 300) { + return null + } + + const displayName = parseJsonBody>(response.body)?.data?.plan + ?.displayName + + // Same wording as Edge's own `contextSwitcher.planLabel`. + return displayName ? `${displayName} Plan` : null + } catch { + return null + } +} + +/** Sign in with an email and password. */ +export async function signIn(email: string, password: string): Promise { + try { + const response = await edgeRequest('/auth/signin', { method: 'POST', json: { email, password } }) + + if (response.status === 401) { + return { status: 'invalid-credentials' } + } + + if (response.status < 200 || response.status >= 300) { + return { status: 'failed' } + } + + const payload = parseJsonBody>(response.body)?.data + + // A verified account comes back with tokens; an unverified one comes back with + // `accessToken: null` and the SAME 200. Reporting that as a failed sign-in sends + // someone with the right password hunting for a wrong one. + if (!payload?.accessToken) { + return { status: 'email-unverified', email } + } + + if (!adoptTokens(payload)) { + // A usable access token with nothing to renew it with is half a session: it + // would die in 7 days with no way back. Better to fail now. + return { status: 'failed' } + } + + return await completeSignIn(payload.user) + } catch { + return { status: 'failed' } + } +} + +/** + * Adopt a pair harvested from a provider flow. + * + * Separate from `signIn` because a provider flow produces no password and hands its + * tokens over out of band. + */ +export async function adoptProviderTokens(pair: TokenPair): Promise { + if (!adoptTokens(pair)) { + return { status: 'failed' } + } + + return completeSignIn(undefined) +} + +/** + * Finish a sign-in by naming the user. + * + * Only a positive read will do: `no-session` and `unknown` both mean we cannot say + * who just signed in, which is a failed sign-in either way. Holding tokens we cannot + * attribute to anyone would show an account menu with no name in it. + */ +async function completeSignIn(known: EdgeUser | undefined): Promise { + if (known) { + return { status: 'signed-in', user: known } + } + + const read = await fetchUser() + + if (read.status !== 'signed-in') { + forgetSession() + + return { status: 'failed' } + } + + return { status: 'signed-in', user: read.user } +} + +/** + * End the session. + * + * Local state is cleared before the request is even attempted. Someone who asked to + * sign out must end up signed out even with the network down; leaving them looking at + * an account they just left is the worse outcome, and the server-side token expires + * on its own regardless. + */ +export async function signOut(): Promise { + const stored = readRefreshToken() + + forgetSession() + + if (!stored) { + return + } + + try { + await edgeRequest('/auth/logout', { method: 'POST', json: { refreshToken: stored } }) + } catch { + // The token is the server's to revoke; it expires regardless. + } +} + +/** Whether a session on this machine survives a restart. Surfaced to the UI. */ +export { isEncryptionAvailable } from './session-store' + +/** Test seam: drop in-memory state without touching what is on disk. */ +export function __resetInMemorySessionForTests(): void { + accessToken = null + accessTokenExpiresAtMs = 0 + renewal = null +} diff --git a/src/backend/editor/edge-account/edge-http.ts b/src/backend/editor/edge-account/edge-http.ts new file mode 100644 index 000000000..bbe104b66 --- /dev/null +++ b/src/backend/editor/edge-account/edge-http.ts @@ -0,0 +1,157 @@ +/** + * HTTP to the Autonomy Edge API, for the desktop editor's account session. + * + * WHY NOT THE CATALOG TRANSPORT. `desktop-catalog-transport` rejects on any non-2xx, + * which is right for browsing a public catalog: there a 404 and a dropped connection + * are equally "no catalog". Authentication cannot live with that. A 401 means the + * credentials were wrong, a 404 on the subscription route means the account has no + * plan, and a transport failure means NOTHING was established about the session. + * Collapsing those into one thrown error is exactly the bug `EdgeUserRead`'s + * `unknown` case exists to prevent — a two-second network blip must not be reported + * as "you are signed out". + * + * So: this resolves with the status for every answer the server gives, and rejects + * only when the server never answered. + * + * WHY IT LIVES IN THE MAIN PROCESS. The renderer is not on Edge's origin, so a + * direct call from there is cross-origin against a host that has no reason to allow + * it. The same reasoning already sends the library catalog through here. Built on the + * same `httpModuleFor` primitive so `OPENPLC_EDGE_API_URL` can point at a local + * backend over plain http. + */ + +import type https from 'https' + +import { defaultPortFor, httpModuleFor } from '../utils/http-module' + +/** Default base URL when no env override is set. Mirrors the catalog transport. */ +const DEFAULT_EDGE_API_URL = 'https://api.autonomylogic.com' + +/** + * Short on purpose: every call here has a user waiting on a sign-in button or an + * avatar. The catalog can afford 30s for a multi-hundred-KB archive; an auth round + * trip that takes more than 15s has already failed as far as the user is concerned. + */ +const REQUEST_TIMEOUT_MS = 15_000 + +/** The Edge API origin, honouring the same override the catalog transport reads. */ +export function getEdgeApiBaseUrl(): string { + const fromEnv = process.env.OPENPLC_EDGE_API_URL?.trim() + + return fromEnv && fromEnv.length > 0 ? fromEnv.replace(/\/+$/, '') : DEFAULT_EDGE_API_URL +} + +export interface EdgeHttpResponse { + status: number + body: string +} + +export interface EdgeRequestInit { + method?: 'GET' | 'POST' | 'DELETE' + /** Serialised and sent as `application/json`. */ + json?: unknown + /** + * A body that is already bytes, with its own content type — the multipart form + * `POST /projects/import` wants, which no amount of JSON can express. + * + * Mutually exclusive with `json`; `json` wins if both are somehow set, because a + * caller passing both has a bug and picking the structured one keeps the failure + * legible instead of sending a form the server cannot parse. + */ + raw?: { body: Buffer; contentType: string } + /** Bearer token, for the routes that need one. */ + accessToken?: string | null + /** + * Overrides {@link REQUEST_TIMEOUT_MS}. Version control needs it: committing or + * switching a branch runs a real git operation on the server against a whole + * project, and 15s is a budget sized for an auth round trip, not for that. + */ + timeoutMs?: number +} + +/** + * One request to the Edge API. + * + * Resolves for every HTTP answer, including 4xx and 5xx — read `status` to decide + * what happened. Rejects only when there was no answer at all (offline, DNS, refused + * connection, timeout), which is the caller's signal that nothing was learned rather + * than that something was denied. + */ +export function edgeRequest(path: string, init: EdgeRequestInit = {}): Promise { + return new Promise((resolve, reject) => { + const url = new URL(path.startsWith('/') ? path : `/${path}`, `${getEdgeApiBaseUrl()}/`) + const json = init.json === undefined ? undefined : JSON.stringify(init.json) + // Bytes either way, so one write path serves both. A JSON string is encoded here + // rather than by `req.write`'s default so its Content-Length below is measured on + // exactly what goes out. + const payload = json !== undefined ? Buffer.from(json, 'utf-8') : init.raw?.body + + const headers: Record = { + Accept: 'application/json', + 'User-Agent': 'OpenPLC-Editor/edge-account', + } + + if (payload !== undefined) { + // Byte length, not string length. A password with non-ASCII characters makes + // the two differ, and a short Content-Length truncates the body server-side + // into a validation error that reads like a wrong password. + headers['Content-Type'] = json !== undefined ? 'application/json' : (init.raw?.contentType ?? 'application/json') + headers['Content-Length'] = String(payload.length) + } + + if (init.accessToken) { + headers.Authorization = `Bearer ${init.accessToken}` + } + + const options: https.RequestOptions = { + hostname: url.hostname, + port: url.port || defaultPortFor(url), + path: url.pathname + url.search, + method: init.method ?? 'GET', + headers, + } + + // Scheme-driven, so OPENPLC_EDGE_API_URL can point at the dev backend on + // http://localhost:3333 without sending a TLS handshake to a plain socket. + const req = httpModuleFor(url).request(options, (res) => { + let body = '' + res.setEncoding('utf-8') + res.on('data', (chunk: string) => { + body += chunk + }) + res.on('end', () => { + resolve({ status: res.statusCode ?? 0, body }) + }) + }) + + const timeoutMs = init.timeoutMs ?? REQUEST_TIMEOUT_MS + + req.setTimeout(timeoutMs, () => { + req.destroy(new Error(`Edge account request timed out after ${timeoutMs}ms`)) + }) + + req.on('error', reject) + + if (payload !== undefined) { + req.write(payload) + } + + req.end() + }) +} + +/** + * Parse a JSON envelope, tolerating anything. + * + * Every failure mode — empty body, a proxy's HTML error page, a truncated response — + * means "the server did not tell us what we asked", and every caller treats a missing + * field the same way. Returning null rather than throwing keeps that decision in one + * place instead of wrapping each call site in a try. + */ +export function parseJsonBody(body: string): T | null { + try { + return JSON.parse(body) as T + } catch { + return null + } +} diff --git a/src/backend/editor/edge-account/oauth-window.ts b/src/backend/editor/edge-account/oauth-window.ts new file mode 100644 index 000000000..accd503b7 --- /dev/null +++ b/src/backend/editor/edge-account/oauth-window.ts @@ -0,0 +1,204 @@ +/** + * Provider sign-in (Google / Microsoft / Apple) for the desktop editor. + * + * WHY A WINDOW WE OWN, AND NOT THE SYSTEM BROWSER. Edge's OAuth callback hands the + * session over as `httpOnly` cookies scoped to `COOKIE_DOMAIN`, then redirects to a + * URL whose origin must match the server's `EDITOR_URL`. Nothing about the tokens + * travels in the redirect. So the standard native-app pattern — system browser plus a + * loopback listener — has nothing to catch: the tokens land in a cookie jar this + * process cannot read, inside a browser it does not control. Driving the flow in a + * `BrowserWindow` we own makes the jar ours, and Electron's cookie API reads + * `httpOnly` values. + * + * KNOWN LIMIT, READ THIS BEFORE DEBUGGING A FAILURE. Google's policy refuses OAuth in + * embedded browsers and can answer `disallowed_useragent` instead of a consent + * screen. The desktop-Chrome user agent below is what makes it work in practice, but + * it is a heuristic against a policy, not a contract. The durable fix is server-side: + * an Edge endpoint that exchanges a one-time code for tokens, which would let this run + * in the real system browser the way RFC 8252 intends. That change belongs to + * autonomy-edge; until it exists, this is the only route that works from here. + * + * A FRESH PARTITION PER ATTEMPT is not a detail. Reusing one keeps the previous Google + * account signed in inside the window, so a user who picked the wrong account could + * never pick another — the next attempt would skip the chooser and hand back the same + * identity, which reads as the app ignoring them. + */ + +import { BrowserWindow, session } from 'electron' + +import type { EdgeOAuthProviderId } from '../../../middleware/shared/ports/edge-account-port' +import { getEdgeApiBaseUrl } from './edge-http' + +/** + * A current desktop Chrome UA. Electron's default advertises `Electron/x.y` and the + * app name, which is precisely what embedded-browser detection looks for. + */ +const DESKTOP_USER_AGENT = + 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36' + +/** + * Long enough for a real person to find a password, pick an account and clear a + * two-factor prompt. Anything tighter closes the window under someone mid-flow. + */ +const FLOW_TIMEOUT_MS = 5 * 60 * 1000 + +export type OAuthFlowResult = + | { status: 'tokens'; accessToken: string; refreshToken: string } + /** The user closed the window. Not an error, and nothing to report to them. */ + | { status: 'cancelled' } + /** The flow ran and produced no session. */ + | { status: 'failed'; reason?: string } + +/** The providers, as they appear in Edge's own `/auth/{provider}` routes. */ +const PROVIDER_IDS: readonly EdgeOAuthProviderId[] = ['google', 'microsoft', 'apple'] + +/** + * Recognise a provider sign-in link the renderer asked to open. + * + * Matched on PATH ONLY, deliberately, not on origin. The shared sign-in dialog builds + * its provider links from the Edge WEB origin, because that is the only Edge URL a + * renderer bundle knows; the real endpoint is on the API origin, which is a + * main-process env var. Rather than plumb that into the bundle, the renderer's URL is + * treated as a statement of intent — "start a Google sign-in" — and this process builds + * the actual request from its own configuration. Matching on origin would force the two + * to agree about something only one of them can know. + * + * Returns null for everything else, which keeps ordinary links going to the system + * browser. + */ +export function edgeOAuthProviderFromUrl(url: string): EdgeOAuthProviderId | null { + try { + const path = new URL(url).pathname.replace(/\/+$/, '') + + return PROVIDER_IDS.find((provider) => path === `/auth/${provider}`) ?? null + } catch { + return null + } +} + +/** + * Where a provider flow starts. + * + * `state=editor` is the marker Edge's callback reads to know which app began the + * flow. It sends the browser to the server's own `EDITOR_URL` afterwards, which on a + * desktop install is a page we neither need nor can reach — irrelevant, because the + * cookies are set by the response that issues that redirect, and we read them from + * our own jar rather than from wherever it points. + */ +function providerUrl(provider: EdgeOAuthProviderId): string { + return `${getEdgeApiBaseUrl()}/auth/${provider}?state=editor` +} + +/** + * Run a provider flow to completion. + * + * Resolves once the session cookies appear in our partition, when the user closes the + * window, or on timeout. Never rejects: every outcome is one the caller has to render, + * not an exception to propagate. + */ +export function runOAuthFlow(provider: EdgeOAuthProviderId): Promise { + return new Promise((resolve) => { + // Unique per attempt, and without the `persist:` prefix so it dies with the + // window rather than remembering the provider account. + const partition = `edge-oauth-${provider}-${process.hrtime.bigint().toString(36)}` + const oauthSession = session.fromPartition(partition) + + oauthSession.setUserAgent(DESKTOP_USER_AGENT) + + const win = new BrowserWindow({ + width: 520, + height: 720, + title: 'Sign in to Autonomy Edge', + autoHideMenuBar: true, + webPreferences: { + partition, + // This window renders a third party's login page. It gets no bridge, no Node + // and no access to anything of ours: it exists only to let the provider talk + // to Edge. + nodeIntegration: false, + contextIsolation: true, + sandbox: true, + }, + }) + + win.setMenuBarVisibility(false) + + let settled = false + + const finish = (result: OAuthFlowResult) => { + if (settled) { + return + } + + settled = true + clearTimeout(timer) + + // Destroy rather than close: `close` would run the closed handler below and + // report a cancellation over the real result. + if (!win.isDestroyed()) { + win.destroy() + } + + void oauthSession.clearStorageData().catch(() => undefined) + + resolve(result) + } + + const timer = setTimeout(() => { + finish({ status: 'failed', reason: 'timed-out' }) + }, FLOW_TIMEOUT_MS) + + /** + * Look for the session in our jar. + * + * Polled on every navigation rather than matched against an expected URL: the + * redirect target is the server's `EDITOR_URL`, which this process has no way to + * know. The cookies appearing IS the completion signal, and a more honest one + * than a URL guess. + */ + const checkForSession = async () => { + if (settled) { + return + } + + try { + const cookies = await oauthSession.cookies.get({}) + const refreshToken = cookies.find((cookie) => cookie.name === 'refreshToken')?.value + const accessToken = cookies.find((cookie) => cookie.name === 'accessToken')?.value + + if (refreshToken && accessToken) { + finish({ status: 'tokens', accessToken, refreshToken }) + } + } catch { + // A jar we could not read is not a completed flow. Wait for the next + // navigation rather than declaring failure on one bad read. + } + } + + // Any of these can be the moment the cookies land — `did-fail-load` included, and + // that one matters: the callback redirects to EDITOR_URL, which on a desktop + // install is often unreachable. The redirect failing to load is irrelevant, since + // the cookies were set by the response that issued it. + win.webContents.on('did-navigate', () => void checkForSession()) + win.webContents.on('did-redirect-navigation', () => void checkForSession()) + win.webContents.on('did-finish-load', () => void checkForSession()) + win.webContents.on('did-fail-load', () => void checkForSession()) + + // Edge sends a failed flow to the editor's `/unauthorized?reason=oauth_failed`. + // Recognising it lets the user see a real message instead of a window that sits + // there until the timeout. + win.webContents.on('will-navigate', (_event, url) => { + if (url.includes('reason=oauth_failed')) { + finish({ status: 'failed', reason: 'provider-declined' }) + } + }) + + win.on('closed', () => { + finish({ status: 'cancelled' }) + }) + + win.loadURL(providerUrl(provider), { userAgent: DESKTOP_USER_AGENT }).catch(() => { + finish({ status: 'failed', reason: 'could-not-open-provider' }) + }) + }) +} diff --git a/src/backend/editor/edge-account/session-store.ts b/src/backend/editor/edge-account/session-store.ts new file mode 100644 index 000000000..6c65bf404 --- /dev/null +++ b/src/backend/editor/edge-account/session-store.ts @@ -0,0 +1,115 @@ +/** + * Where the desktop editor keeps its Edge session between runs. + * + * Only the refresh token is persisted. The access token deliberately is not: it lives + * 7 days, so a copy on disk is a week-long credential for anyone who reads the file, + * and it can always be re-minted from the refresh token in one round trip. + * + * WHY IT MAY REFUSE TO PERSIST. `safeStorage` is backed by the Keychain on macOS, + * DPAPI on Windows, and a Secret Service keyring on Linux. On a Linux box with no + * keyring there is no key, and `encryptString` either throws or — worse, on some + * Electron versions — degrades to plaintext. Writing a bearer credential to a + * world-readable JSON file is not an acceptable degradation, so when encryption is + * unavailable the session is kept in memory for the run and the user signs in again + * next launch. Losing that convenience is the right trade. + * + * The desktop cannot use the shared parent-domain cookie the web editor relies on — + * its renderer is not on Edge's origin — which is why this file exists at all. + */ + +import { safeStorage } from 'electron' + +import { store } from '../../../main/modules/store' + +/** Held for the run when the OS refused to encrypt, so the session still works. */ +let inMemoryRefreshToken: string | null = null + +/** + * Whether the OS can encrypt. Probed through a function rather than a module-level + * constant because `safeStorage` is only meaningful once the app is ready, and this + * module can be imported before that. + */ +function canEncrypt(): boolean { + try { + return safeStorage.isEncryptionAvailable() + } catch { + return false + } +} + +/** + * Persist the refresh token, encrypted when the OS allows it. + * + * Rotation makes this a hot path: every renewal issues a new token and kills the old + * one, so a write that silently failed would leave the stored value one rotation + * behind the server and sign the user out on the next launch. Hence the return value + * — callers can tell "kept in memory only" from "written". + */ +export function saveRefreshToken(token: string): { persisted: boolean } { + inMemoryRefreshToken = token + + if (!canEncrypt()) { + return { persisted: false } + } + + try { + store.set('edge_session', { refreshToken: safeStorage.encryptString(token).toString('base64') }) + + return { persisted: true } + } catch { + // Encryption was advertised but failed. Treated exactly like no encryption: + // never fall back to writing the raw token. + return { persisted: false } + } +} + +/** The stored refresh token, or null when there is nothing usable. */ +export function readRefreshToken(): string | null { + if (inMemoryRefreshToken) { + return inMemoryRefreshToken + } + + const encrypted = store.get('edge_session')?.refreshToken + + if (!encrypted || !canEncrypt()) { + // Either nothing was stored, or it was written on a machine that could encrypt + // and is being read on one that cannot. The bytes are not recoverable. + return null + } + + try { + const token = safeStorage.decryptString(Buffer.from(encrypted, 'base64')) + inMemoryRefreshToken = token + + return token + } catch { + // Undecryptable: a different OS user, a reset keychain, a corrupted value. Drop + // it rather than retrying on every request for the rest of the run. + clearRefreshToken() + + return null + } +} + +/** + * Forget the session, in memory and on disk. + * + * Called on sign-out and whenever a renewal is refused. A refresh token the server + * has revoked is worse than none, because keeping it makes every launch begin with a + * failing request that looks like an outage. + */ +export function clearRefreshToken(): void { + inMemoryRefreshToken = null + + try { + store.delete('edge_session') + } catch { + // A store that cannot delete cannot be repaired from here, and the in-memory + // copy is already gone. + } +} + +/** Whether a session on this machine survives a restart. Surfaced to the UI. */ +export function isEncryptionAvailable(): boolean { + return canEncrypt() +} diff --git a/src/backend/editor/edge-project-upload/__tests__/edge-project-upload.test.ts b/src/backend/editor/edge-project-upload/__tests__/edge-project-upload.test.ts new file mode 100644 index 000000000..e356105ad --- /dev/null +++ b/src/backend/editor/edge-project-upload/__tests__/edge-project-upload.test.ts @@ -0,0 +1,369 @@ +/** + * Publishing a local project to Edge, with HTTP stubbed and a real directory on disk. + * + * The archive is built from an actual temporary project rather than from a mocked + * filesystem, because what matters here is what ends up INSIDE the zip: the importer + * refuses an archive with no `project.json` at its root, and it reads paths with forward + * slashes. A mock would happily agree with whatever the code did. + * + * The other half is the limits. They are the server's, mirrored locally so a doomed upload + * fails before someone waits out a zip and a slow connection for a rejection that was + * certain from the start. + */ + +import fs from 'fs/promises' +import os from 'os' +import path from 'path' + +import JSZip from 'jszip' + +import { edgeAuthedRequest } from '../../edge-account/edge-account-service' +import { buildProjectArchive, listCloudFolders, uploadProjectToCloud } from '..' + +jest.mock('../../edge-account/edge-account-service', () => ({ + edgeAuthedRequest: jest.fn(), +})) + +const request = edgeAuthedRequest as jest.MockedFunction + +let projectDir: string + +/** A project on disk, as the editor would have written one. */ +async function writeProject(files: Record): Promise { + for (const [relative, contents] of Object.entries(files)) { + const absolute = path.join(projectDir, relative) + await fs.mkdir(path.dirname(absolute), { recursive: true }) + await fs.writeFile(absolute, contents) + } +} + +/** The entry names inside a built archive. */ +async function entriesOf(zip: Buffer): Promise { + const loaded = await JSZip.loadAsync(zip) + + return Object.keys(loaded.files) + .filter((name) => !loaded.files[name].dir) + .sort() +} + +beforeEach(async () => { + jest.clearAllMocks() + projectDir = await fs.mkdtemp(path.join(os.tmpdir(), 'openplc-upload-')) +}) + +afterEach(async () => { + await fs.rm(projectDir, { recursive: true, force: true }) +}) + +describe('building the archive', () => { + it('packs the project files with the manifest at the root', async () => { + await writeProject({ + 'project.json': '{"meta":{"name":"Irrigation"}}', + 'pous/programs/main.st': 'x := TRUE;', + 'devices/configuration.json': '{}', + }) + + const result = await buildProjectArchive(projectDir) + + expect(result.ok).toBe(true) + + if (result.ok) { + // Forward slashes, and `project.json` at the top — both are what the importer + // looks for. `path.join` would emit backslashes on Windows, which the server then + // reads as characters in a filename rather than as folders. + expect(await entriesOf(result.zip)).toEqual([ + 'devices/configuration.json', + 'pous/programs/main.st', + 'project.json', + ]) + expect(result.fileCount).toBe(3) + } + }) + + it('leaves out files the importer would not accept', async () => { + await writeProject({ + 'project.json': '{}', + 'pous/programs/main.st': 'x;', + '.DS_Store': 'junk', + 'build/output.bin': 'binary', + 'notes.txt': 'personal', + }) + + const result = await buildProjectArchive(projectDir) + + // Dropped rather than fatal: a stray OS file or a build artefact is not a reason to + // refuse to publish someone's work. + expect(result.ok && (await entriesOf(result.zip))).toEqual(['pous/programs/main.st', 'project.json']) + }) + + it('refuses a folder that is not an OpenPLC project', async () => { + await writeProject({ 'pous/programs/main.st': 'x;' }) + + // Checked here so the user is told the folder is wrong, rather than being handed the + // server's accurate but unhelpful "archive must contain a project.json". + await expect(buildProjectArchive(projectDir)).resolves.toEqual({ ok: false, failure: { reason: 'no-manifest' } }) + }) + + it('refuses a folder with nothing in it', async () => { + await expect(buildProjectArchive(projectDir)).resolves.toEqual({ ok: false, failure: { reason: 'empty' } }) + }) + + it('names the file that is too big, not just the fact', async () => { + await writeProject({ 'project.json': '{}' }) + // 50MB + 1 byte, in an allowed extension so it is not simply skipped. A Uint8Array + // rather than a Buffer: this project's TS/@types/node pairing rejects a Buffer here, + // the same mismatch the runtime uploader documents. + await fs.writeFile(path.join(projectDir, 'huge.st'), new Uint8Array(50 * 1024 * 1024 + 1)) + + const result = await buildProjectArchive(projectDir) + + expect(result.ok).toBe(false) + + if (!result.ok) { + expect(result.failure.reason).toBe('file-too-large') + // The path, because "a file is too large" in a project of hundreds is not actionable. + expect(result.failure).toMatchObject({ relativePath: 'huge.st' }) + } + }) + + it('refuses a project nested deeper than the importer allows', async () => { + const deep = Array.from({ length: 12 }, (_, i) => `d${i}`).join('/') + await writeProject({ 'project.json': '{}', [`${deep}/main.st`]: 'x;' }) + + await expect(buildProjectArchive(projectDir)).resolves.toMatchObject({ failure: { reason: 'too-deep' } }) + }) + + it('reports a directory it cannot read instead of publishing a partial project', async () => { + await expect(buildProjectArchive(path.join(projectDir, 'does-not-exist'))).resolves.toMatchObject({ + ok: false, + failure: { reason: 'unreadable' }, + }) + }) +}) + +describe('uploading', () => { + beforeEach(async () => { + await writeProject({ 'project.json': '{"meta":{"name":"Irrigation"}}', 'pous/programs/main.st': 'x;' }) + }) + + /** The multipart body of the nth call, as text (the zip bytes are not valid UTF-8). */ + function sentBody(index = 0): string { + const init = request.mock.calls[index][1] + + return init && 'raw' in init && init.raw ? init.raw.body.toString('latin1') : '' + } + + it('posts a multipart form with the destination and visibility', async () => { + request.mockResolvedValueOnce({ status: 201, body: JSON.stringify({ data: { project: { id: 'p9' } } }) }) + + const result = await uploadProjectToCloud({ + projectPath: projectDir, + parentFolderId: 'folder-1', + visibility: 'private', + }) + + expect(result).toEqual({ status: 'ok', projectId: 'p9', uploadedFiles: 2 }) + + const [path_, init] = request.mock.calls[0] + expect(path_).toBe('/projects/import') + expect(init).toMatchObject({ method: 'POST' }) + expect(init && 'raw' in init && init.raw?.contentType).toMatch(/^multipart\/form-data; boundary=/) + + const body = sentBody() + expect(body).toContain('name="parentFolderId"') + expect(body).toContain('folder-1') + expect(body).toContain('name="visibility"') + expect(body).toContain('private') + expect(body).toContain('name="file"; filename=') + expect(body).toContain('Content-Type: application/zip') + }) + + it('omits the name when the user did not change it', async () => { + request.mockResolvedValueOnce({ status: 201, body: '{}' }) + + await uploadProjectToCloud({ projectPath: projectDir, parentFolderId: 'f1', visibility: 'private' }) + + // Absent means "use the name in project.json", which is what the importer does. + expect(sentBody()).not.toContain('name="projectName"') + }) + + it('sends a name the user did choose', async () => { + request.mockResolvedValueOnce({ status: 201, body: '{}' }) + + await uploadProjectToCloud({ + projectPath: projectDir, + parentFolderId: 'f1', + projectName: 'Renamed Project', + visibility: 'public', + }) + + const body = sentBody() + expect(body).toContain('Renamed Project') + expect(body).toContain('public') + }) + + it('cannot be made to forge a header through the filename', async () => { + request.mockResolvedValueOnce({ status: 201, body: '{}' }) + const nasty = await fs.mkdtemp(path.join(os.tmpdir(), 'evil"\r\nX-Injected: 1')) + + try { + await fs.writeFile(path.join(nasty, 'project.json'), '{}') + await uploadProjectToCloud({ projectPath: nasty, parentFolderId: 'f1', visibility: 'private' }) + + const lines = sentBody().split('\r\n') + + // The property that matters is that the break is gone, not that the text is: a + // forged header would have to start its own line. The characters survive inside the + // filename, harmlessly, which is why asserting on the substring would be asserting + // the wrong thing. + expect(lines.some((line) => line.startsWith('X-Injected'))).toBe(false) + expect(lines.filter((line) => line.includes('filename='))).toHaveLength(1) + } finally { + await fs.rm(nasty, { recursive: true, force: true }) + } + }) + + it('does not upload at all when the archive could not be built', async () => { + const empty = await fs.mkdtemp(path.join(os.tmpdir(), 'openplc-empty-')) + + try { + await expect( + uploadProjectToCloud({ projectPath: empty, parentFolderId: 'f1', visibility: 'private' }), + ).resolves.toMatchObject({ failure: { reason: 'empty' } }) + expect(request).not.toHaveBeenCalled() + } finally { + await fs.rm(empty, { recursive: true, force: true }) + } + }) + + it('reports no session', async () => { + request.mockResolvedValueOnce(null) + + await expect( + uploadProjectToCloud({ projectPath: projectDir, parentFolderId: 'f1', visibility: 'private' }), + ).resolves.toEqual({ status: 'failed', failure: { reason: 'signed-out' } }) + }) + + it('passes the server refusal through, because it says what is wrong', async () => { + request.mockResolvedValueOnce({ + status: 409, + body: JSON.stringify({ message: 'Project with name "Irrigation" already exists for this user' }), + }) + + await expect( + uploadProjectToCloud({ projectPath: projectDir, parentFolderId: 'f1', visibility: 'private' }), + ).resolves.toEqual({ + status: 'failed', + failure: { + reason: 'rejected', + status: 409, + message: 'Project with name "Irrigation" already exists for this user', + }, + }) + }) + + it('does NOT claim the upload failed when the server never answered', async () => { + request.mockRejectedValueOnce(new Error('ECONNRESET')) + + // The import is not idempotent: an unanswered POST may have created the project. + // Calling that a failure invites a duplicate. + await expect( + uploadProjectToCloud({ projectPath: projectDir, parentFolderId: 'f1', visibility: 'private' }), + ).resolves.toMatchObject({ failure: { reason: 'unreachable' } }) + }) + + it('still succeeds when the response carries no project id', async () => { + request.mockResolvedValueOnce({ status: 201, body: 'not json' }) + + // The project was created; we just cannot link to it. + await expect( + uploadProjectToCloud({ projectPath: projectDir, parentFolderId: 'f1', visibility: 'private' }), + ).resolves.toEqual({ status: 'ok', projectId: null, uploadedFiles: 2 }) + }) +}) + +describe('listing destinations', () => { + const tree = [ + { + id: 'root-1', + name: 'cmsdgkcy3000407lmry3tj53c', + type: 'root', + deletedAt: null, + children: [ + { id: 'dir-1', name: 'Machines', type: 'directory', deletedAt: null, children: [] }, + { id: 'proj-1', name: 'Irrigation', type: 'project', deletedAt: null, children: [] }, + { id: 'dir-2', name: 'Old', type: 'directory', deletedAt: '2026-08-01T00:00:00.000Z', children: [] }, + ], + }, + ] + + function respond(folders: unknown) { + request.mockResolvedValueOnce({ status: 200, body: JSON.stringify({ data: { folders } }) }) + } + + it('asks for the hierarchy, so the list can be indented', async () => { + respond([]) + + await listCloudFolders() + + expect(request.mock.calls[0][0]).toBe('/folders?includeHierarchy=true') + }) + + it('offers folders, not projects, and never the bin', async () => { + respond(tree) + + // A project folder IS a project; offering it would invite nesting one inside another. + // A trashed folder would accept the import and then be invisible. + await expect(listCloudFolders()).resolves.toEqual({ + status: 'ok', + folders: [ + { id: 'root-1', name: 'Root (/)', depth: 0 }, + { id: 'dir-1', name: 'Machines', depth: 1 }, + ], + }) + }) + + it('never shows the account id as a folder name', async () => { + respond(tree) + + const result = await listCloudFolders() + + // The root folder is named after the user on the wire — meaningless and slightly + // alarming in a menu. Same label Edge's own dialog uses. + expect(result.status === 'ok' && result.folders[0].name).toBe('Root (/)') + }) + + it('keeps walking past a project folder to the directories under it', async () => { + respond([ + { + id: 'p1', + type: 'project', + deletedAt: null, + children: [{ id: 'inner', name: 'Shared', type: 'directory', deletedAt: null, children: [] }], + }, + ]) + + await expect(listCloudFolders()).resolves.toEqual({ + status: 'ok', + folders: [{ id: 'inner', name: 'Shared', depth: 1 }], + }) + }) + + it('reports no session rather than an empty account', async () => { + request.mockResolvedValueOnce({ status: 401, body: '{}' }) + + await expect(listCloudFolders()).resolves.toEqual({ status: 'signed-out' }) + }) + + it('reports unreachable on a transport failure', async () => { + request.mockRejectedValueOnce(new Error('ENOTFOUND')) + + await expect(listCloudFolders()).resolves.toEqual({ status: 'unreachable' }) + }) + + it('drops a folder with no id instead of listing something unclickable', async () => { + respond([{ name: 'No id', type: 'directory', deletedAt: null, children: [] }]) + + await expect(listCloudFolders()).resolves.toEqual({ status: 'ok', folders: [] }) + }) +}) diff --git a/src/backend/editor/edge-project-upload/index.ts b/src/backend/editor/edge-project-upload/index.ts new file mode 100644 index 000000000..65e9e37bc --- /dev/null +++ b/src/backend/editor/edge-project-upload/index.ts @@ -0,0 +1,404 @@ +/** + * Publishing a project from this machine to Autonomy Edge. + * + * The desktop's counterpart to the web's "Import project" dialog, and it drives the same + * endpoint — `POST /projects/import`, multipart, with a zip and a destination folder. The + * web asks the user to produce that zip by hand ("right-click the project folder → + * Compress"); here the project is already on disk with a path we hold, so the editor makes + * the archive itself. That is the entire difference between the two flows. + * + * WHY THE LIMITS ARE ENFORCED HERE TOO. The server rejects an archive that is too big, has + * too many files, nests too deep, or carries a file type it does not accept. Re-checking + * before the upload is not distrust of the server: zipping a large project and pushing it + * over a slow connection takes real time, and finding out afterwards that it was never + * going to be accepted wastes all of it. The numbers are the server's own, and if they + * drift, a rejection still lands — this only makes the common failures immediate and + * specific instead of late and generic. + */ + +import fs from 'fs/promises' +import JSZip from 'jszip' +import path from 'path' + +import { edgeAuthedRequest } from '../edge-account/edge-account-service' +import { parseJsonBody } from '../edge-account/edge-http' + +/** + * The extensions `POST /projects/import` accepts. Anything else in the project directory + * is left out of the archive rather than making the upload fail — a stray `.DS_Store`, an + * editor backup or a build artefact is not a reason to refuse to publish someone's work. + */ +const ALLOWED_EXTENSIONS = new Set(['.json', '.st', '.fbd', '.ld', '.il', '.py']) + +/** The server's own ceilings, mirrored so a doomed upload fails before it is attempted. */ +const MAX_FILE_BYTES = 50 * 1024 * 1024 +const MAX_TOTAL_BYTES = 100 * 1024 * 1024 +const MAX_FILES = 1000 +const MAX_DEPTH = 10 + +/** A project without this is not a project the importer can read. */ +const PROJECT_MANIFEST = 'project.json' + +/** Zipping and uploading a whole project is not a request with a user tapping their foot. */ +const UPLOAD_TIMEOUT_MS = 300_000 + +/** Listing folders is. */ +const LIST_TIMEOUT_MS = 30_000 + +// --------------------------------------------------------------------------- +// Folders — the destination picker's data +// --------------------------------------------------------------------------- + +export interface CloudFolder { + id: string + /** Already display-ready: see `labelFor`. */ + name: string + /** Nesting level, so a flat list can still read as a tree. */ + depth: number +} + +export type CloudFoldersResult = + | { status: 'ok'; folders: CloudFolder[] } + | { status: 'signed-out' } + | { status: 'unreachable' } + +/** The shape `GET /folders?includeHierarchy=true` returns, narrowed to what is used. */ +interface RawFolder { + id?: unknown + name?: unknown + type?: unknown + deletedAt?: unknown + children?: unknown +} + +function isRawFolder(value: unknown): value is RawFolder { + return typeof value === 'object' && value !== null +} + +/** + * What to call a folder in the picker. + * + * The root folder's `name` is the account's own user id — an internal detail that would be + * meaningless and slightly alarming in a menu. The web's dialog shows `Root (/)` for it; + * this says the same thing in the same place. + */ +function labelFor(folder: RawFolder): string { + if (folder.type === 'root') { + return 'Root (/)' + } + + return typeof folder.name === 'string' && folder.name.length > 0 ? folder.name : 'Untitled folder' +} + +/** + * Flatten the hierarchy into the list the picker shows. + * + * Only `root` and `directory` survive. A `project` folder IS a project, and offering it as + * a destination would invite someone to nest a project inside another one — the web's + * dialog filters the same two types for the same reason. Trashed folders are dropped as + * well: importing into the bin would succeed and then be invisible. + */ +function flattenFolders(nodes: unknown, depth = 0): CloudFolder[] { + if (!Array.isArray(nodes) || depth > MAX_DEPTH) { + return [] + } + + const out: CloudFolder[] = [] + + for (const node of nodes) { + if (!isRawFolder(node) || typeof node.id !== 'string' || node.id.length === 0) { + continue + } + + if (node.deletedAt !== null && node.deletedAt !== undefined) { + continue + } + + if (node.type !== 'root' && node.type !== 'directory') { + // Not a destination — but a project folder can still contain directories, so keep + // walking rather than pruning the branch. + out.push(...flattenFolders(node.children, depth + 1)) + continue + } + + out.push({ id: node.id, name: labelFor(node), depth }) + out.push(...flattenFolders(node.children, depth + 1)) + } + + return out +} + +export async function listCloudFolders(): Promise { + let response: { status: number; body: string } | null + + try { + response = await edgeAuthedRequest('/folders?includeHierarchy=true', { timeoutMs: LIST_TIMEOUT_MS }) + } catch { + return { status: 'unreachable' } + } + + if (!response) { + return { status: 'signed-out' } + } + + if (response.status === 401 || response.status === 403) { + return { status: 'signed-out' } + } + + if (response.status >= 400) { + return { status: 'unreachable' } + } + + const payload = parseJsonBody<{ data?: { folders?: unknown } }>(response.body) + + return { status: 'ok', folders: flattenFolders(payload?.data?.folders) } +} + +// --------------------------------------------------------------------------- +// Archiving +// --------------------------------------------------------------------------- + +interface CollectedFile { + /** Forward-slash separated, relative to the project directory. */ + relativePath: string + contents: Buffer +} + +export type UploadFailure = + | { reason: 'no-manifest' } + | { reason: 'empty' } + | { reason: 'too-many-files'; count: number } + | { reason: 'too-deep' } + | { reason: 'file-too-large'; relativePath: string; bytes: number } + | { reason: 'too-large'; bytes: number } + | { reason: 'unreadable'; message: string } + | { reason: 'signed-out' } + | { reason: 'unreachable'; message: string } + | { reason: 'rejected'; status: number; message: string } + +export type UploadProjectResult = + | { status: 'ok'; projectId: string | null; uploadedFiles: number } + | { status: 'failed'; failure: UploadFailure } + +/** + * Read the project directory into memory, keeping only what the importer accepts. + * + * In memory because the archive has to be a single buffer for the multipart body anyway, + * and the ceiling on that is 100MB — small enough that streaming to a temporary file + * would add a cleanup path and a failure mode without buying anything. + */ +async function collectFiles( + projectPath: string, + directory: string, + prefix: string, + depth: number, + collected: CollectedFile[], +): Promise { + if (depth > MAX_DEPTH) { + return { reason: 'too-deep' } + } + + let entries: Awaited> + + try { + entries = await fs.readdir(directory, { withFileTypes: true }) + } catch (error) { + return { reason: 'unreadable', message: error instanceof Error ? error.message : 'Could not read the project' } + } + + for (const entry of entries) { + const absolute = path.join(directory, entry.name) + // Forward slashes, always: the ZIP spec's separator. `path.join` would emit + // backslashes on Windows, which the server then reads as literal characters in a + // filename rather than as directories — the same trap the runtime upload documents. + const relativePath = prefix ? `${prefix}/${entry.name}` : entry.name + + if (entry.isDirectory()) { + const failure = await collectFiles(projectPath, absolute, relativePath, depth + 1, collected) + + if (failure) { + return failure + } + + continue + } + + // Symlinks are skipped rather than followed: a link pointing outside the project + // would quietly publish files the user never meant to share. + if (!entry.isFile()) { + continue + } + + if (!ALLOWED_EXTENSIONS.has(path.extname(entry.name).toLowerCase())) { + continue + } + + let contents: Buffer + + try { + contents = await fs.readFile(absolute) + } catch (error) { + return { + reason: 'unreadable', + message: error instanceof Error ? error.message : `Could not read ${relativePath}`, + } + } + + if (contents.length > MAX_FILE_BYTES) { + return { reason: 'file-too-large', relativePath, bytes: contents.length } + } + + collected.push({ relativePath, contents }) + + if (collected.length > MAX_FILES) { + return { reason: 'too-many-files', count: collected.length } + } + } + + return null +} + +/** Build the archive the importer expects: project files at the root, `project.json` among them. */ +export async function buildProjectArchive( + projectPath: string, +): Promise<{ ok: true; zip: Buffer; fileCount: number } | { ok: false; failure: UploadFailure }> { + const collected: CollectedFile[] = [] + const failure = await collectFiles(projectPath, projectPath, '', 0, collected) + + if (failure) { + return { ok: false, failure } + } + + if (collected.length === 0) { + return { ok: false, failure: { reason: 'empty' } } + } + + // Checked here rather than trusting the server's message: "the archive must contain a + // project.json in the root" is true but unhelpful when the user picked a folder that was + // never an OpenPLC project at all. + if (!collected.some((file) => file.relativePath === PROJECT_MANIFEST)) { + return { ok: false, failure: { reason: 'no-manifest' } } + } + + const total = collected.reduce((sum, file) => sum + file.contents.length, 0) + + if (total > MAX_TOTAL_BYTES) { + return { ok: false, failure: { reason: 'too-large', bytes: total } } + } + + const zip = new JSZip() + + for (const file of collected) { + zip.file(file.relativePath, file.contents) + } + + return { + ok: true, + // DEFLATE, not STORE: the payload is source text and JSON, which compresses hard, and + // the 100MB ceiling is measured on what is sent. + zip: await zip.generateAsync({ type: 'nodebuffer', compression: 'DEFLATE' }), + fileCount: collected.length, + } +} + +// --------------------------------------------------------------------------- +// Upload +// --------------------------------------------------------------------------- + +/** Strips CR/LF and quotes so a filename cannot forge a header or break the disposition. */ +function headerSafe(value: string): string { + return value.replace(/[\r\n"]/g, '') +} + +/** One text field of a multipart form. */ +function textPart(boundary: string, name: string, value: string): Buffer { + return Buffer.from(`--${boundary}\r\nContent-Disposition: form-data; name="${headerSafe(name)}"\r\n\r\n${value}\r\n`) +} + +export interface UploadProjectParams { + projectPath: string + parentFolderId: string + /** Overrides the name in `project.json`. */ + projectName?: string + visibility: 'public' | 'private' +} + +export async function uploadProjectToCloud(params: UploadProjectParams): Promise { + const archive = await buildProjectArchive(params.projectPath) + + if (!archive.ok) { + return { status: 'failed', failure: archive.failure } + } + + const boundary = `----OpenPLCEditorBoundary${Math.random().toString(36).slice(2)}` + const zipName = `${path.basename(params.projectPath) || 'project'}.zip` + + const parts: Buffer[] = [ + textPart(boundary, 'parentFolderId', params.parentFolderId), + textPart(boundary, 'visibility', params.visibility), + ] + + if (params.projectName) { + parts.push(textPart(boundary, 'projectName', params.projectName)) + } + + parts.push( + Buffer.from( + `--${boundary}\r\n` + + `Content-Disposition: form-data; name="file"; filename="${headerSafe(zipName)}"\r\n` + + `Content-Type: application/zip\r\n\r\n`, + ), + archive.zip, + Buffer.from(`\r\n--${boundary}--\r\n`), + ) + + // `Buffer.concat` is typed over `Uint8Array`, and this project's TS/@types/node pairing + // will not take a `Buffer` there. A view over the same memory satisfies it without + // copying the archive, and without the assertion the runtime uploader had to document. + const body = Buffer.concat(parts.map((part) => new Uint8Array(part.buffer, part.byteOffset, part.byteLength))) + + let response: { status: number; body: string } | null + + try { + response = await edgeAuthedRequest('/projects/import', { + method: 'POST', + raw: { body, contentType: `multipart/form-data; boundary=${boundary}` }, + timeoutMs: UPLOAD_TIMEOUT_MS, + }) + } catch (error) { + // No answer at all, which for a non-idempotent POST means the project MAY have been + // created. Said as "unreachable" rather than "failed" so the message can tell the user + // to check Edge before retrying instead of implying nothing happened. + return { + status: 'failed', + failure: { reason: 'unreachable', message: error instanceof Error ? error.message : 'No answer' }, + } + } + + if (!response) { + return { status: 'failed', failure: { reason: 'signed-out' } } + } + + if (response.status === 401) { + return { status: 'failed', failure: { reason: 'signed-out' } } + } + + if (response.status >= 400) { + const parsed = parseJsonBody<{ message?: string | string[]; error?: { message?: string | string[] } }>( + response.body, + ) + const raw = parsed?.message ?? parsed?.error?.message + const message = Array.isArray(raw) ? raw.join('; ') : (raw ?? `Autonomy Edge answered ${response.status}.`) + + return { status: 'failed', failure: { reason: 'rejected', status: response.status, message } } + } + + const created = parseJsonBody<{ data?: { project?: { id?: unknown } } }>(response.body) + const projectId = created?.data?.project?.id + + return { + status: 'ok', + projectId: typeof projectId === 'string' ? projectId : null, + uploadedFiles: archive.fileCount, + } +} diff --git a/src/backend/editor/edge-projects/__tests__/edge-projects.test.ts b/src/backend/editor/edge-projects/__tests__/edge-projects.test.ts new file mode 100644 index 000000000..32ce3148e --- /dev/null +++ b/src/backend/editor/edge-projects/__tests__/edge-projects.test.ts @@ -0,0 +1,356 @@ +/** + * The cloud round trip, with HTTP stubbed. + * + * Three things here are worth protecting, and each has bitten a real codebase: + * + * - a remote list is narrowed field by field, not cast. A row missing an id would + * otherwise become a card the user can click and nothing happens. + * - a partial save is read-modify-write, and the read is MANDATORY: the backend deletes + * by omission, so sending only the changed file wipes the rest of the project. + * - "not signed in", "denied" and "unreachable" are three different answers, and the + * user needs a different thing from each. + */ + +import { edgeAuthedRequest } from '../../edge-account/edge-account-service' +import { listRecentCloudProjects, readCloudProject, saveCloudFile, saveCloudProject } from '..' + +jest.mock('../../edge-account/edge-account-service', () => ({ + edgeAuthedRequest: jest.fn(), +})) + +const request = edgeAuthedRequest as jest.MockedFunction + +function ok(data: unknown) { + return { status: 200, body: JSON.stringify({ data }) } +} + +/** The envelope shape the API returns under `files`. */ +const FILES = { + 'project.json': '{"meta":{"name":"Irrigation","type":"plc-project"}}', + pous: { programs: { 'main.st': 'x := TRUE;' } }, + devices: { 'configuration.json': '{}', 'pin-mapping.json': '[]' }, +} + +beforeEach(() => { + jest.clearAllMocks() +}) + +describe('listRecentCloudProjects', () => { + it('asks the server for the newest first, and for no more than the limit', async () => { + request.mockResolvedValueOnce(ok({ projects: [] })) + + await listRecentCloudProjects(5) + + const [path] = request.mock.calls[0] + const query = new URL(path, 'https://example.test').searchParams + + // Ordering is the server's: the five newest of ten fetched rows are not the five + // newest overall, so sorting a truncated page here would be wrong. + expect(query.get('limit')).toBe('5') + expect(query.get('sortBy')).toBe('updatedAt') + expect(query.get('sortOrder')).toBe('desc') + }) + + it('maps the rows it can use', async () => { + request.mockResolvedValueOnce( + ok({ + projects: [ + { id: 'p1', name: 'Irrigation', language: 'st', updatedAt: '2026-08-24T19:40:51.962Z' }, + { id: 'p2', name: 'No language', language: null, updatedAt: '2026-08-23T10:00:00.000Z' }, + ], + }), + ) + + await expect(listRecentCloudProjects(5)).resolves.toEqual({ + status: 'ok', + projects: [ + { id: 'p1', name: 'Irrigation', language: 'st', updatedAt: '2026-08-24T19:40:51.962Z' }, + { id: 'p2', name: 'No language', language: null, updatedAt: '2026-08-23T10:00:00.000Z' }, + ], + }) + }) + + it('drops rows it cannot open instead of listing them', async () => { + request.mockResolvedValueOnce( + ok({ + projects: [ + { name: 'No id at all', updatedAt: '2026-08-24T00:00:00.000Z' }, + { id: 'p2', updatedAt: '2026-08-24T00:00:00.000Z' }, + { id: 'p3', name: 'Fine', updatedAt: '2026-08-24T00:00:00.000Z' }, + null, + ], + }), + ) + + // A card with no id is a card that does nothing when clicked. + await expect(listRecentCloudProjects(5)).resolves.toEqual({ + status: 'ok', + projects: [{ id: 'p3', name: 'Fine', language: null, updatedAt: '2026-08-24T00:00:00.000Z' }], + }) + }) + + /** + * The three kinds of nothing, kept apart. Collapsing them into an empty list is what + * makes a start screen tell an offline user to sign in — sending them to fix a problem + * they do not have. + */ + it('reports no session when there is no token to use', async () => { + request.mockResolvedValueOnce(null) + + await expect(listRecentCloudProjects(5)).resolves.toEqual({ status: 'signed-out' }) + }) + + it.each([401, 403])('reports no session when the server answers %i', async (status) => { + request.mockResolvedValueOnce({ status, body: '{}' }) + + await expect(listRecentCloudProjects(5)).resolves.toEqual({ status: 'signed-out' }) + }) + + it('reports unreachable on a transport failure, NOT signed out', async () => { + request.mockRejectedValueOnce(new Error('ENOTFOUND')) + + await expect(listRecentCloudProjects(5)).resolves.toEqual({ status: 'unreachable' }) + }) + + it('reports unreachable on a 5xx, which says nothing about the session', async () => { + request.mockResolvedValueOnce({ status: 503, body: '' }) + + await expect(listRecentCloudProjects(5)).resolves.toEqual({ status: 'unreachable' }) + }) + + it.each([ + ['a payload with no projects array', ok({})], + ['an unparseable body', { status: 200, body: 'not json' }], + ])('reports an empty account for %s', async (_label, response) => { + // The server answered and the session is fine; there is simply nothing to list. + request.mockResolvedValueOnce(response as never) + + await expect(listRecentCloudProjects(5)).resolves.toEqual({ status: 'ok', projects: [] }) + }) +}) + +describe('readCloudProject', () => { + it('translates the envelope into the shape the filesystem reader returns', async () => { + request.mockResolvedValueOnce(ok({ files: FILES, capabilities: { canEdit: true } })) + + const result = await readCloudProject('p1') + + expect(result.success).toBe(true) + expect(result.data?.projectPath).toBe('p1') + expect(result.data?.canEdit).toBe(true) + expect(result.data?.pouFiles).toEqual([{ relativePath: 'pous/programs/main.st', content: 'x := TRUE;' }]) + }) + + it('sends the build id, so the endpoint does not answer with a hard-refresh stub', async () => { + request.mockResolvedValueOnce(ok({ files: FILES })) + + await readCloudProject('p1') + + expect(request.mock.calls[0][0]).toMatch(/\/projects\/p1\/details\?uncached_version=.+/) + }) + + it('carries a read-only project through as read-only', async () => { + request.mockResolvedValueOnce(ok({ files: FILES, capabilities: { canEdit: false } })) + + // Offering a save that the server will refuse is worse than not offering one. + await expect(readCloudProject('p1')).resolves.toMatchObject({ data: { canEdit: false } }) + }) + + it('says so plainly when there is no session', async () => { + request.mockResolvedValueOnce(null) + + const result = await readCloudProject('p1') + + expect(result.success).toBe(false) + expect(result.error?.title).toBe('Not signed in') + }) + + it('carries the status so a denial is not reported as a broken project', async () => { + request.mockResolvedValueOnce({ status: 403, body: '{}' }) + + await expect(readCloudProject('p1')).resolves.toMatchObject({ + success: false, + error: { status: 403 }, + }) + }) + + it('distinguishes unreachable from denied', async () => { + request.mockRejectedValueOnce(new Error('ENOTFOUND')) + + const result = await readCloudProject('p1') + + // "You are offline" and "this project is broken" call for completely different things + // from the user. + expect(result.error?.title).toBe('Could not reach Autonomy Edge') + }) + + it('fails when the payload carries no files', async () => { + request.mockResolvedValueOnce(ok({ capabilities: { canEdit: true } })) + + await expect(readCloudProject('p1')).resolves.toMatchObject({ success: false }) + }) +}) + +describe('saveCloudFile', () => { + it('reads the whole envelope, patches one slot and sends it all back', async () => { + request + // the mandatory read + .mockResolvedValueOnce(ok({ files: structuredClone(FILES) })) + // the write + .mockResolvedValueOnce({ status: 200, body: '{}' }) + + await expect(saveCloudFile('p1/pous/programs/main.st', 'x := FALSE;')).resolves.toEqual({ success: true }) + + const [path, init] = request.mock.calls[1] + expect(path).toBe('/projects/p1/files/save') + + const sent = (init as { json: { files: typeof FILES } }).json.files + + // The patched slot changed... + expect(sent.pous.programs['main.st']).toBe('x := FALSE;') + // ...and everything else is still there. The backend deletes by omission, so a + // partial body would wipe the rest of the project. + expect(sent['project.json']).toBe(FILES['project.json']) + expect(sent.devices['pin-mapping.json']).toBe('[]') + }) + + it('serialises a non-string payload', async () => { + request.mockResolvedValueOnce(ok({ files: structuredClone(FILES) })).mockResolvedValueOnce({ + status: 200, + body: '{}', + }) + + await saveCloudFile('p1/devices/configuration.json', { baudRate: 9600 }) + + const sent = (request.mock.calls[1][1] as { json: { files: { devices: Record } } }).json.files + + expect(JSON.parse(sent.devices['configuration.json'])).toEqual({ baudRate: 9600 }) + }) + + it('refuses a path with no project id rather than guessing one', async () => { + await expect(saveCloudFile('main.st', 'x := TRUE;')).resolves.toMatchObject({ success: false }) + expect(request).not.toHaveBeenCalled() + }) + + it('does not write when the mandatory read failed', async () => { + request.mockResolvedValueOnce({ status: 500, body: '{}' }) + + const result = await saveCloudFile('p1/pous/programs/main.st', 'x := FALSE;') + + expect(result.success).toBe(false) + // One call: the read. Writing after a failed read would send an envelope built from + // nothing and delete the project. + expect(request).toHaveBeenCalledTimes(1) + }) +}) + +describe('saveCloudProject', () => { + const files = { + projectPath: 'p1', + projectJson: '{"meta":{"name":"Irrigation"}}', + deviceConfig: '{}', + pinMapping: '[]', + libraryManifest: '', + pouFiles: [{ relativePath: 'pous/programs/main.st', content: 'x := TRUE;' }], + serverFiles: [], + remoteDeviceFiles: [], + dataTypeFiles: [], + deletions: [], + } + + it('posts the whole envelope for the project', async () => { + request.mockResolvedValueOnce({ status: 200, body: '{}' }) + + await expect(saveCloudProject(files)).resolves.toEqual({ success: true }) + expect(request.mock.calls[0][0]).toBe('/projects/p1/files/save') + }) + + it('omits deletions when there are none, and sends them when there are', async () => { + request.mockResolvedValueOnce({ status: 200, body: '{}' }) + await saveCloudProject(files) + expect((request.mock.calls[0][1] as { json: Record }).json).not.toHaveProperty('deletions') + + request.mockResolvedValueOnce({ status: 200, body: '{}' }) + await saveCloudProject({ ...files, deletions: ['pous/programs/old.st', ''] }) + + // The empty entry is dropped: an empty path would ask the backend to delete the + // project root. + expect((request.mock.calls[1][1] as { json: { deletions: string[] } }).json.deletions).toEqual([ + 'pous/programs/old.st', + ]) + }) + + it('reports a refusal rather than claiming success', async () => { + request.mockResolvedValueOnce({ status: 403, body: '{}' }) + + await expect(saveCloudProject(files)).resolves.toMatchObject({ success: false }) + }) + + it('reports no session', async () => { + request.mockResolvedValueOnce(null) + + await expect(saveCloudProject(files)).resolves.toEqual({ + success: false, + error: 'Not signed in to Autonomy Edge.', + }) + }) + + it('reports a transport failure instead of throwing', async () => { + request.mockRejectedValueOnce(new Error('ECONNRESET')) + + await expect(saveCloudProject(files)).resolves.toMatchObject({ success: false, error: 'ECONNRESET' }) + }) +}) + +/** + * The bytes as loaded. + * + * The save flow echoes these back for files the user did not touch. Without them every save + * re-serialises the whole project in the editor's own formatting — same meaning, different + * bytes — which grew a real project from 62KB to 147KB and reported every file as modified + * against HEAD. The web build has had this since it shipped; these tests are the desktop + * catching up, so they check the same keys the web adapter produces. + */ +describe('readCloudProject carries the raw bytes', () => { + it('keys the project and device files exactly as the save flow asks for them', async () => { + request.mockResolvedValueOnce(ok({ files: FILES })) + + const result = await readCloudProject('p1') + + expect(result.data?.rawLoadedFiles).toMatchObject({ + 'project.json': FILES['project.json'], + 'devices/configuration.json': '{}', + 'devices/pin-mapping.json': '[]', + }) + }) + + it('includes every POU under its own relative path', async () => { + request.mockResolvedValueOnce(ok({ files: FILES })) + + const result = await readCloudProject('p1') + + // The same path the parsed `pouFiles` entry carries, because that is the key + // `pickContentForSave` looks up. + expect(result.data?.rawLoadedFiles?.['pous/programs/main.st']).toBe('x := TRUE;') + }) + + it('hands back the bytes verbatim, not a re-serialisation', async () => { + // Deliberately ugly formatting: the point of the map is that it survives untouched. + const ugly = '{\n\t"meta" : {"name":"Irrigation"} }' + request.mockResolvedValueOnce(ok({ files: { ...FILES, 'project.json': ugly } })) + + const result = await readCloudProject('p1') + + expect(result.data?.rawLoadedFiles?.['project.json']).toBe(ugly) + }) + + it('leaves out what the save flow never asks about', async () => { + request.mockResolvedValueOnce(ok({ files: { ...FILES, 'README.md': '# hi' } })) + + const result = await readCloudProject('p1') + + // A key nobody reads is a key that can only drift. README is not produced by the save + // flow, so echoing it would not save it either — that gap is its own problem. + expect(result.data?.rawLoadedFiles).not.toHaveProperty('README.md') + }) +}) diff --git a/src/backend/editor/edge-projects/index.ts b/src/backend/editor/edge-projects/index.ts new file mode 100644 index 000000000..0bb8a0d47 --- /dev/null +++ b/src/backend/editor/edge-projects/index.ts @@ -0,0 +1,301 @@ +/** + * The user's Autonomy Edge projects, from the desktop editor. + * + * Three operations, and they are the whole cloud round trip: list what the account has, + * read one into the shape the editor's project reader already returns, and write one + * back. Everything authenticated goes through `edgeAuthedRequest`, so renewal, the + * single-flight guard and the one retry on a revoked token are not reimplemented here. + * + * THE WIRE FORMAT IS NOT DUPLICATED. `backend/shared/project/api-envelope` owns both + * directions of the envelope and is shared with openplc-web, which reaches the same API. + * That module used to live in the web adapter, which is exactly why the desktop could + * not read a cloud project without a second copy of the same knowledge. + * + * SAVING IS READ-MODIFY-WRITE, and it has to be. The backend deletes by omission, so + * sending only the file that changed would wipe the rest of the project. Every partial + * save therefore loads the current envelope first, patches one slot and sends the whole + * thing — the same contract the web adapter follows. + */ + +import { APP_VERSION } from '../../../frontend/data/constants/app-version' +import type { + CloudProjectsResult, + RawProjectFiles, + WriteProjectFiles, +} from '../../../middleware/shared/ports/project-port' +import { + apiFilesToRaw, + type ApiProjectFiles, + envelopeFromWriteProjectFiles, + setInEnvelope, +} from '../../shared/project/api-envelope' +import { edgeAuthedRequest } from '../edge-account/edge-account-service' +import { parseJsonBody } from '../edge-account/edge-http' + +/** Every successful payload from the API arrives wrapped as `{ data: ... }`. */ +interface Envelope { + data?: T +} + +interface ApiProjectRow { + id?: unknown + name?: unknown + language?: unknown + updatedAt?: unknown +} + +/** + * The most recently changed projects on the account. + * + * Ordered by the server, not here: `updatedAt desc` is what "recent" means, and asking + * the API for it costs nothing while sorting a truncated page locally would be wrong — + * the five newest of ten fetched rows are not the five newest overall. + * + * Reports WHICH kind of nothing it found — no session, nothing to show, or a server it + * could not reach — because the start screen says something different for each. + */ +export async function listRecentCloudProjects(limit: number): Promise { + const query = new URLSearchParams({ limit: String(limit), sortBy: 'updatedAt', sortOrder: 'desc' }) + + let response: { status: number; body: string } | null + + try { + response = await edgeAuthedRequest(`/projects?${query.toString()}`) + } catch { + // Never reached the server. Saying "signed out" here would tell someone who is + // signed in and merely offline to go and sign in again. + return { status: 'unreachable' } + } + + // No token could be obtained, or the server refused one. + if (!response || response.status === 401 || response.status === 403) { + return { status: 'signed-out' } + } + + if (response.status < 200 || response.status >= 300) { + // A 5xx says nothing about the session. + return { status: 'unreachable' } + } + + const rows = parseJsonBody>(response.body)?.data?.projects + + if (!Array.isArray(rows)) { + return { status: 'ok', projects: [] } + } + + // Narrowed field by field rather than cast: this is a remote payload, and a row + // missing an id would otherwise become a list entry that cannot be opened. + const projects = rows.flatMap((row) => { + if (typeof row?.id !== 'string' || typeof row.name !== 'string' || typeof row.updatedAt !== 'string') { + return [] + } + + return [ + { + id: row.id, + name: row.name, + language: typeof row.language === 'string' ? row.language : null, + updatedAt: row.updatedAt, + }, + ] + }) + + return { status: 'ok', projects } +} + +/** + * `/details` for a project, with the build id the endpoint expects. + * + * An editor-origin request that omits `uncached_version` is treated as an outdated + * cached bundle and answered with a synthetic "hard refresh" project instead of the real + * one. The desktop is not that origin, but sending the real version keeps us out of that + * branch by construction rather than by assumption. + */ +function detailsPath(projectId: string): string { + return `/projects/${encodeURIComponent(projectId)}/details?uncached_version=${encodeURIComponent(APP_VERSION)}` +} + +/** The current envelope, or null when it could not be read. */ +async function readEnvelope(projectId: string): Promise { + const response = await edgeAuthedRequest(detailsPath(projectId)) + + if (!response || response.status < 200 || response.status >= 300) { + return null + } + + return parseJsonBody>(response.body)?.data?.files ?? null +} + +/** + * Read a cloud project into the same shape the filesystem reader returns. + * + * `canEdit` rides along from the server's own capabilities rather than being assumed: + * a project shared read-only must not offer a save that will be refused. + */ +/** + * The raw-content map the save flow consults, in the shape the web build produces. + * + * Deliberately the same keys the web adapter uses — `project.json`, the two device files, + * and every POU/server/remote-device path. Anything the save flow does not ask about is + * left out on purpose: a key nobody reads is a key that can only drift. + */ +function rawLoadedFilesFrom(raw: { + projectJson: string + deviceConfig: string + pinMapping: string + pouFiles: Array<{ relativePath: string; content: string }> + serverFiles: Array<{ relativePath: string; content: string }> + remoteDeviceFiles: Array<{ relativePath: string; content: string }> +}): Record { + const map: Record = { + 'project.json': raw.projectJson, + 'devices/configuration.json': raw.deviceConfig, + 'devices/pin-mapping.json': raw.pinMapping, + } + + for (const group of [raw.pouFiles, raw.serverFiles, raw.remoteDeviceFiles]) { + for (const file of group) { + map[file.relativePath] = file.content + } + } + + return map +} + +export async function readCloudProject(projectId: string): Promise { + try { + const response = await edgeAuthedRequest(detailsPath(projectId)) + + if (!response) { + return { + success: false, + error: { title: 'Not signed in', description: 'Sign in to Autonomy Edge to open this project.' }, + } + } + + if (response.status < 200 || response.status >= 300) { + return { + success: false, + error: { + title: 'Failed to open project', + description: `Autonomy Edge answered ${response.status}.`, + // Carried so the caller can tell a permission denial from a broken project. + status: response.status, + }, + } + } + + const payload = parseJsonBody>( + response.body, + )?.data + const files = payload?.files + + if (!files) { + return { + success: false, + error: { title: 'Failed to open project', description: 'Autonomy Edge returned no files.' }, + } + } + + const raw = apiFilesToRaw(projectId, files) + + return { + success: true, + data: { + ...raw, + canEdit: payload?.capabilities?.canEdit, + /** + * The bytes exactly as the API sent them, keyed by path. + * + * The save flow echoes these back for files the user did not edit, instead of + * re-serialising them. Without it every save rewrites every file in the editor's + * own formatting: same meaning, different bytes, so the project grows (62KB to + * 147KB on a real one) and git reports every file as modified. + * + * Built from the parsed result rather than the envelope so the keys match the + * paths the save flow asks about — and match what the web adapter produces, since + * the point is for the two to behave the same. + */ + rawLoadedFiles: rawLoadedFilesFrom(raw), + }, + } + } catch (error) { + // Never reached the server. Said plainly, because "you are offline" and "this project + // is broken" call for completely different things from the user. + return { + success: false, + error: { + title: 'Could not reach Autonomy Edge', + description: error instanceof Error ? error.message : 'Unknown error', + }, + } + } +} + +/** Persist a full envelope. `deletions` is omitted when empty, as the API expects. */ +async function writeEnvelope( + projectId: string, + files: ApiProjectFiles, + deletions: string[], +): Promise<{ success: boolean; error?: string }> { + const response = await edgeAuthedRequest(`/projects/${encodeURIComponent(projectId)}/files/save`, { + method: 'POST', + json: { files, ...(deletions.length > 0 ? { deletions } : {}) }, + }) + + if (!response) { + return { success: false, error: 'Not signed in to Autonomy Edge.' } + } + + if (response.status < 200 || response.status >= 300) { + return { success: false, error: `Autonomy Edge answered ${response.status}.` } + } + + return { success: true } +} + +/** Save a whole cloud project. */ +export async function saveCloudProject(files: WriteProjectFiles): Promise<{ success: boolean; error?: string }> { + try { + return await writeEnvelope( + files.projectPath, + envelopeFromWriteProjectFiles(files), + files.deletions.filter((path) => path.length > 0), + ) + } catch (error) { + return { success: false, error: error instanceof Error ? error.message : 'Save failed' } + } +} + +/** + * Save one file inside a cloud project. + * + * `filePath` is `projectId/relative/path`, the same contract the web adapter uses, which + * is what lets the shared save flow drive both platforms without knowing which it is on. + */ +export async function saveCloudFile(filePath: string, content: unknown): Promise<{ success: boolean; error?: string }> { + try { + const separator = filePath.indexOf('/') + + if (separator === -1) { + return { success: false, error: 'Invalid file path. Expected: projectId/relative/path' } + } + + const projectId = filePath.slice(0, separator) + const relativePath = filePath.slice(separator + 1) + + // Read-modify-write, and the read is mandatory: the backend deletes by omission, so + // sending only this file would wipe every other one. + const envelope = await readEnvelope(projectId) + + if (!envelope) { + return { success: false, error: 'Could not read the project before saving it.' } + } + + setInEnvelope(envelope, relativePath, typeof content === 'string' ? content : JSON.stringify(content)) + + return await writeEnvelope(projectId, envelope, []) + } catch (error) { + return { success: false, error: error instanceof Error ? error.message : 'Save failed' } + } +} diff --git a/src/backend/editor/edge-version-control/__tests__/edge-version-control.test.ts b/src/backend/editor/edge-version-control/__tests__/edge-version-control.test.ts new file mode 100644 index 000000000..f02977e51 --- /dev/null +++ b/src/backend/editor/edge-version-control/__tests__/edge-version-control.test.ts @@ -0,0 +1,451 @@ +/** + * Version control against Edge, with HTTP stubbed. + * + * What these tests protect is SAMENESS. The desktop and the web editor call the same + * seventeen routes, and the promise made to the user is that a commit made from one + * behaves like a commit made from the other. So the assertions here are deliberately + * literal about method, path, query and body — a drifted query param is not a cosmetic + * difference, it is a 400 the whitelist raises, and a dropped payload field is a commit + * that quietly includes the wrong files. + * + * The other half is the failure taxonomy. "No session", "you may not", "it never + * answered" and "these files conflict" are four different things, and the UI has a + * different flow for each. Collapsing any pair of them produces the class of bug where a + * dropped connection tells someone their branch cannot be created. + */ + +import { edgeAuthedRequest } from '../../edge-account/edge-account-service' +import { + applyStash, + getBranchDiffWithBase, + createBranch, + createCommit, + createStash, + deleteBranch, + discardChanges, + dropStash, + getChanges, + getCommitFiles, + listBranches, + listCommits, + listStashes, + mergeBranches, + popStash, + previewSwitchCarry, + restoreCommit, + switchBranch, +} from '..' + +jest.mock('../../edge-account/edge-account-service', () => ({ + edgeAuthedRequest: jest.fn(), +})) + +const request = edgeAuthedRequest as jest.MockedFunction + +/** A successful envelope, in the `{ statusCode, data }` shape every Edge route answers. */ +function ok(data: unknown, status = 200) { + return { status, body: JSON.stringify({ statusCode: status, data }) } +} + +/** The path and init of the nth call. */ +function callArgs(index = 0) { + const [path, init] = request.mock.calls[index] + + return { path, init: init ?? {} } +} + +beforeEach(() => { + jest.clearAllMocks() +}) + +describe('the routes match the ones the web build calls', () => { + it('lists branches', async () => { + request.mockResolvedValueOnce(ok({ branches: [{ id: 'b1', name: 'main' }] })) + + await expect(listBranches('p1')).resolves.toEqual({ ok: true, data: { branches: [{ id: 'b1', name: 'main' }] } }) + expect(callArgs().path).toBe('/projects/p1/branches') + }) + + it('creates a branch by name', async () => { + request.mockResolvedValueOnce(ok({ branch: { id: 'b2', name: 'feature' } })) + + await createBranch('p1', 'feature') + + expect(callArgs()).toMatchObject({ + path: '/projects/p1/branches', + init: { method: 'POST', json: { name: 'feature' } }, + }) + }) + + it('deletes a branch by id, with DELETE', async () => { + request.mockResolvedValueOnce({ status: 204, body: '' }) + + await expect(deleteBranch('p1', 'b2')).resolves.toEqual({ ok: true, data: null }) + expect(callArgs()).toMatchObject({ path: '/projects/p1/branches/b2', init: { method: 'DELETE' } }) + }) + + it('switches a branch, sending the strategy', async () => { + request.mockResolvedValueOnce(ok({ message: 'Switched', branch: 'feature' })) + + await switchBranch('p1', 'feature', 'carry') + + expect(callArgs().init).toMatchObject({ method: 'POST', json: { branchName: 'feature', strategy: 'carry' } }) + }) + + it('previews a carry as a read, not a write', async () => { + request.mockResolvedValueOnce(ok({ conflicts: [] })) + + await previewSwitchCarry('p1', 'feature') + + const { path, init } = callArgs() + expect(path).toBe('/projects/p1/branches/preview-switch-carry?targetBranch=feature') + // No method means GET. A preview that mutated would defeat its purpose. + expect(init.method).toBeUndefined() + }) + + it('encodes a branch name that needs it', async () => { + request.mockResolvedValueOnce(ok({ conflicts: [] })) + + await previewSwitchCarry('p1', 'feat/edge 602') + + expect(callArgs().path).toBe('/projects/p1/branches/preview-switch-carry?targetBranch=feat%2Fedge+602') + }) + + it('paginates commits, omitting what was not asked for', async () => { + request.mockResolvedValueOnce(ok({ commits: [], total: 0, page: 1 })) + await listCommits('p1', { limit: 20, offset: 40, branch: 'main' }) + expect(callArgs().path).toBe('/projects/p1/commits?limit=20&offset=40&branch=main') + + request.mockResolvedValueOnce(ok({ commits: [], total: 0, page: 1 })) + await listCommits('p1') + // No trailing `?`: an empty query string would be sent as a bare question mark. + expect(callArgs(1).path).toBe('/projects/p1/commits') + }) + + it('sends offset zero, which is a real page and not a missing one', async () => { + request.mockResolvedValueOnce(ok({ commits: [], total: 0, page: 1 })) + + await listCommits('p1', { offset: 0 }) + + expect(callArgs().path).toBe('/projects/p1/commits?offset=0') + }) + + it('commits with a message, and only mentions files when given some', async () => { + request.mockResolvedValueOnce(ok({ hash: 'abc' })) + await createCommit('p1', 'Fix the ladder') + expect(callArgs().init).toMatchObject({ method: 'POST', json: { message: 'Fix the ladder' } }) + expect(callArgs().init.json).not.toHaveProperty('files') + + request.mockResolvedValueOnce(ok({ hash: 'def' })) + await createCommit('p1', 'Partial', ['pous/programs/main.st'], 'feature') + expect(callArgs(1).init.json).toEqual({ + message: 'Partial', + files: ['pous/programs/main.st'], + branch: 'feature', + }) + }) + + it('reads a commit with its parent, for diffing', async () => { + request.mockResolvedValueOnce(ok({ files: [], parentFiles: [], commit: { hash: 'abc' } })) + + await getCommitFiles('p1', 'abc', 'feat/x') + + expect(callArgs().path).toBe('/projects/p1/commits/abc/files?branch=feat%2Fx') + }) + + it('restores a commit', async () => { + request.mockResolvedValueOnce(ok({ message: 'Restored', restoredCommit: { hash: 'abc' } })) + + await restoreCommit('p1', 'abc') + + expect(callArgs()).toMatchObject({ path: '/projects/p1/commits/abc/restore', init: { method: 'POST', json: {} } }) + }) + + it('asks for change content only when the caller wants it', async () => { + request.mockResolvedValueOnce(ok({ changes: [], hasChanges: false })) + await getChanges('p1', true) + expect(callArgs().path).toBe('/projects/p1/changes?includeContent=true') + + request.mockResolvedValueOnce(ok({ changes: [], hasChanges: false })) + await getChanges('p1') + expect(callArgs(1).path).toBe('/projects/p1/changes') + }) + + it('never sends a branch on the working-tree routes', async () => { + // The backend's validation whitelist rejects the param outright, and pending changes + // are computed against the checked-out HEAD regardless. Sending it earns a 400 and + // nothing else — the same omission the web adapter documents. + request.mockResolvedValueOnce(ok({ changes: [], hasChanges: false })) + await getChanges('p1', true) + expect(callArgs().path).not.toContain('branch') + + request.mockResolvedValueOnce({ status: 200, body: '{}' }) + await discardChanges('p1', ['a.st']) + expect(JSON.stringify(callArgs(1).init.json)).not.toContain('branch') + }) + + it('lists, creates, applies, pops and drops stashes on their own routes', async () => { + request.mockResolvedValueOnce(ok({ stashes: [] })) + await listStashes('p1') + expect(callArgs().path).toBe('/projects/p1/stashes') + + request.mockResolvedValueOnce(ok({ stash: { hash: 's1' } })) + await createStash('p1', 'wip', ['a.st']) + expect(callArgs(1).init).toMatchObject({ method: 'POST', json: { message: 'wip', files: ['a.st'] } }) + + request.mockResolvedValueOnce(ok({ message: 'Applied' })) + await applyStash('p1', 's1') + expect(callArgs(2)).toMatchObject({ path: '/projects/p1/stashes/apply', init: { json: { ref: 's1' } } }) + + request.mockResolvedValueOnce(ok({ message: 'Popped' })) + await popStash('p1', 's1') + expect(callArgs(3).path).toBe('/projects/p1/stashes/pop') + + request.mockResolvedValueOnce({ status: 200, body: '{}' }) + await dropStash('p1', 's1') + expect(callArgs(4).path).toBe('/projects/p1/stashes/drop') + }) + + it('omits an empty file list from a stash rather than sending one', async () => { + request.mockResolvedValueOnce(ok({ stash: { hash: 's1' } })) + + await createStash('p1', undefined, []) + + // An empty array would ask the server to stash nothing at all, which is not what + // "stash everything" means. + expect(callArgs().init.json).toEqual({}) + }) + + it('allows git the time git needs', async () => { + request.mockResolvedValueOnce(ok({ branches: [] })) + + await listBranches('p1') + + // 30s, matching the web build's axios timeout, so the same commit on the same + // project gives up at the same point on both platforms. The 15s default is sized + // for an auth round trip. + expect(callArgs().init).toMatchObject({ timeoutMs: 30_000 }) + }) +}) + +describe('the four kinds of failure stay apart', () => { + it('reports no session when there is no token to spend', async () => { + request.mockResolvedValueOnce(null) + + await expect(listBranches('p1')).resolves.toEqual({ ok: false, failure: { kind: 'signed-out' } }) + }) + + it('reports no session on a 401 that survived a renewal', async () => { + request.mockResolvedValueOnce({ status: 401, body: '{}' }) + + await expect(listBranches('p1')).resolves.toEqual({ ok: false, failure: { kind: 'signed-out' } }) + }) + + it('keeps a 403 apart from being signed out', async () => { + request.mockResolvedValueOnce({ status: 403, body: JSON.stringify({ message: 'Read-only project' }) }) + + // Signing in again cannot fix a project the account may read but not write, so + // telling the user to do that would send them in a circle. + await expect(createBranch('p1', 'x')).resolves.toEqual({ + ok: false, + failure: { kind: 'http', status: 403, message: 'Read-only project' }, + }) + }) + + it('reports unreachable on a transport failure, NOT a refusal', async () => { + request.mockRejectedValueOnce(new Error('ENOTFOUND')) + + await expect(createBranch('p1', 'x')).resolves.toEqual({ + ok: false, + failure: { kind: 'unreachable', message: 'ENOTFOUND' }, + }) + }) + + it('reports a 500 as an HTTP failure with its status', async () => { + request.mockResolvedValueOnce({ status: 500, body: '' }) + + await expect(listBranches('p1')).resolves.toEqual({ + ok: false, + failure: { kind: 'http', status: 500, message: 'Autonomy Edge answered 500.' }, + }) + }) + + it('joins a validation error list into something readable', async () => { + request.mockResolvedValueOnce({ + status: 400, + body: JSON.stringify({ message: ['name must be a string', 'name should not be empty'] }), + }) + + await expect(createBranch('p1', 'x')).resolves.toMatchObject({ + failure: { message: 'name must be a string; name should not be empty' }, + }) + }) + + it('does not report an unreadable 2xx body as a success', async () => { + request.mockResolvedValueOnce({ status: 200, body: 'gateway' }) + + // A proxy's error page with a 200 on it is not a branch list. + await expect(listBranches('p1')).resolves.toMatchObject({ ok: false, failure: { kind: 'http' } }) + }) +}) + +describe('the two conflicts the UI can recover from', () => { + it('names a blocked carry, with the files that blocked it', async () => { + request.mockResolvedValueOnce({ + status: 409, + // Top level, not inside `data` — the same place the web adapter reads it from. + body: JSON.stringify({ hasConflicts: true, conflictedFiles: ['pous/programs/main.st'] }), + }) + + await expect(switchBranch('p1', 'feature', 'carry')).resolves.toEqual({ + ok: false, + failure: { kind: 'carry-conflict', conflictedFiles: ['pous/programs/main.st'] }, + }) + }) + + it('treats a 409 without hasConflicts as an ordinary failure', async () => { + request.mockResolvedValueOnce({ status: 409, body: JSON.stringify({ message: 'Branch already exists' }) }) + + // Only `hasConflicts` means a carry was rejected. Reading every 409 on the route as + // a carry conflict would reopen the conflict modal with an empty file list. + await expect(switchBranch('p1', 'feature', 'carry')).resolves.toEqual({ + ok: false, + failure: { kind: 'http', status: 409, message: 'Branch already exists' }, + }) + }) + + it('names a stash that will not apply cleanly', async () => { + request.mockResolvedValueOnce({ status: 409, body: '{}' }) + + await expect(applyStash('p1', 's1')).resolves.toEqual({ ok: false, failure: { kind: 'stash-conflict' } }) + }) + + it('names the same for pop, where the stash is kept', async () => { + request.mockResolvedValueOnce({ status: 409, body: '{}' }) + + await expect(popStash('p1', 's1')).resolves.toEqual({ ok: false, failure: { kind: 'stash-conflict' } }) + }) + + it('does not invent a conflict on a route that cannot have one', async () => { + request.mockResolvedValueOnce({ status: 409, body: '{}' }) + + // `createBranch` passes no 409 handler, so a name collision stays what it is. + await expect(createBranch('p1', 'main')).resolves.toMatchObject({ failure: { kind: 'http', status: 409 } }) + }) +}) + +describe('the routes whose answer nobody reads', () => { + it.each([ + ['a 204 with no body', { status: 204, body: '' }], + ['a 200 with an empty object', { status: 200, body: '{}' }], + ['a 200 with a real envelope', { status: 200, body: JSON.stringify({ statusCode: 200, data: {} }) }], + ])('treats %s as success', async (_label, response) => { + request.mockResolvedValueOnce(response) + + await expect(dropStash('p1', 's1')).resolves.toEqual({ ok: true, data: null }) + }) + + it('still reports a real failure on those routes', async () => { + request.mockResolvedValueOnce({ status: 403, body: '{}' }) + + await expect(discardChanges('p1')).resolves.toMatchObject({ ok: false, failure: { status: 403 } }) + }) +}) + +describe('merging', () => { + it('asks for the three-way diff with both branches named', async () => { + request.mockResolvedValueOnce(ok({ source: {}, target: {}, base: null, conflicts: [] })) + + await getBranchDiffWithBase('p1', 'feature', 'main') + + expect(callArgs().path).toBe('/projects/p1/branches-diff-with-base?source=feature&target=main') + }) + + it('encodes branch names that need it', async () => { + request.mockResolvedValueOnce(ok({ conflicts: [] })) + + await getBranchDiffWithBase('p1', 'feat/edge 602', 'main') + + expect(callArgs().path).toContain('source=feat%2Fedge+602') + }) + + it('posts the merge with both branches and the message', async () => { + request.mockResolvedValueOnce(ok({ message: 'Merged', mergeCommit: { hash: 'abc' } })) + + await mergeBranches({ + projectId: 'p1', + sourceBranch: 'feature', + targetBranch: 'main', + commitMessage: 'Merge feature', + }) + + expect(callArgs()).toMatchObject({ + path: '/projects/p1/branches/merge', + init: { method: 'POST', json: { sourceBranch: 'feature', targetBranch: 'main', commitMessage: 'Merge feature' } }, + }) + }) + + it('omits the message and resolutions when there are none', async () => { + request.mockResolvedValueOnce(ok({ message: 'Merged' })) + + await mergeBranches({ projectId: 'p1', sourceBranch: 'feature', targetBranch: 'main' }) + + const json = callArgs().init.json as Record + expect(json).toEqual({ sourceBranch: 'feature', targetBranch: 'main' }) + }) + + it('sends resolutions when the user decided per file', async () => { + request.mockResolvedValueOnce(ok({ message: 'Merged' })) + + await mergeBranches({ + projectId: 'p1', + sourceBranch: 'feature', + targetBranch: 'main', + resolutions: { 'pous/programs/main.st': 'x := TRUE;' }, + }) + + expect((callArgs().init.json as { resolutions: Record }).resolutions).toEqual({ + 'pous/programs/main.st': 'x := TRUE;', + }) + }) + + it('names a conflict as its own kind, with the files', async () => { + request.mockResolvedValueOnce({ + status: 409, + // Top level, like the carry rejection — the same place the web adapter reads it. + body: JSON.stringify({ + hasConflicts: true, + conflictedFiles: ['pous/programs/main.st'], + message: 'Merge conflicts detected', + }), + }) + + await expect(mergeBranches({ projectId: 'p1', sourceBranch: 'feature', targetBranch: 'main' })).resolves.toEqual({ + ok: false, + failure: { + kind: 'merge-conflict', + conflictedFiles: ['pous/programs/main.st'], + message: 'Merge conflicts detected', + }, + }) + }) + + it('treats a 409 without hasConflicts as an ordinary failure', async () => { + request.mockResolvedValueOnce({ status: 409, body: JSON.stringify({ message: 'Nothing to merge' }) }) + + // Otherwise the resolver opens with an empty file list. + await expect( + mergeBranches({ projectId: 'p1', sourceBranch: 'feature', targetBranch: 'main' }), + ).resolves.toMatchObject({ failure: { kind: 'http', status: 409 } }) + }) + + it('does NOT report a merge as failed when the server never answered', async () => { + request.mockRejectedValueOnce(new Error('ECONNRESET')) + + // A merge writes a commit. An unanswered POST may well have made one, so calling it a + // failure invites the user to merge the same branch twice. + await expect( + mergeBranches({ projectId: 'p1', sourceBranch: 'feature', targetBranch: 'main' }), + ).resolves.toMatchObject({ failure: { kind: 'unreachable' } }) + }) +}) diff --git a/src/backend/editor/edge-version-control/index.ts b/src/backend/editor/edge-version-control/index.ts new file mode 100644 index 000000000..3e4fa1d21 --- /dev/null +++ b/src/backend/editor/edge-version-control/index.ts @@ -0,0 +1,375 @@ +/** + * Version control against Autonomy Edge, from the desktop main process. + * + * The git repository lives beside the project on the server: Edge's own worker runs the + * commits, the branch switches and the stashes. So this module is a transport and nothing + * more — the same role the web build's adapter plays, hitting the same seventeen routes + * with the same payloads. That is deliberate and it is the whole design: `carry` conflict + * detection, stash semantics and restore are real git behaviour implemented once, on the + * server, and reimplementing any of it here would produce a desktop that *looks* like the + * web editor and disagrees with it under load. + * + * WHY IT RETURNS RESULTS INSTEAD OF THROWING. The renderer's UI branches on + * `error instanceof SwitchBranchCarryConflictError` and `error instanceof + * StashConflictError`. A class instance does not survive the structured clone that IPC + * puts it through — the prototype is lost and every `instanceof` silently answers false, + * which would turn "these files conflict, pick discard or cancel" into a console error and + * a switch that appears to do nothing. So failures cross the boundary as plain data with a + * `kind`, and the adapter on the other side builds the real error object back. The typed + * failures are the reason this file exists in this shape. + * + * WHY THE MAIN PROCESS AT ALL. The renderer is not on Edge's origin, and the session's + * access token is held here (encrypted at rest) rather than being handed to the renderer. + * Every authenticated call the editor makes already goes through `edgeAuthedRequest`, + * which owns renewal and the single retry. + */ + +import type { VersionControlFailure, VersionControlResult } from '../../../middleware/shared/ports/version-control-port' +import { edgeAuthedRequest } from '../edge-account/edge-account-service' +import { parseJsonBody } from '../edge-account/edge-http' + +/** + * Git work against a whole project is not an auth round trip. Matches the web build's + * axios timeout exactly, so the same commit on the same project gives up at the same + * point on both platforms. + */ +const VC_TIMEOUT_MS = 30_000 + +// --------------------------------------------------------------------------- +// Result shape — serialisable, because it crosses IPC +// --------------------------------------------------------------------------- + +/** + * Why each of these is kept apart rather than collapsed into a message: + * + * - `signed-out` — there is no session to spend. The user signs in; nothing is wrong + * with the project. + * - `unreachable` — the server never answered, so NOTHING was learned. Reporting this as + * a denial would tell someone their branch cannot be created when the truth is that + * their wifi dropped. + * - `carry-conflict` / `stash-conflict` — the two cases the UI has real recovery flows + * for. They carry exactly what those flows need. + * - `http` — everything else, with the status, so a 403 on a read-only project reads + * differently from a 500. + */ +export type EdgeVcFailure = VersionControlFailure + +export type EdgeVcResult = VersionControlResult + +/** The `{ statusCode, data }` envelope every Edge route answers with. */ +interface EdgeEnvelope { + statusCode?: number + data?: T +} + +/** + * Pull something readable out of a failure body. + * + * Nest's exception filter puts the reason in `message`, which may be a string or an array + * of validation strings. Falling back to the status keeps the UI from showing an empty + * toast when a proxy answers with HTML. + */ +function messageFromBody(body: string, status: number): string { + const parsed = parseJsonBody<{ message?: string | string[] }>(body) + const raw = parsed?.message + + if (Array.isArray(raw) && raw.length > 0) { + return raw.join('; ') + } + + if (typeof raw === 'string' && raw.length > 0) { + return raw + } + + return `Autonomy Edge answered ${status}.` +} + +/** + * One authenticated call, with the failure taxonomy applied. + * + * `on409` is how the two conflict flows get their own kind. Only the routes that can + * conflict pass it, so a 409 anywhere else stays an ordinary HTTP failure rather than + * being mistaken for a conflict the UI knows how to resolve. + */ +async function call( + path: string, + init: { method?: 'GET' | 'POST' | 'DELETE'; json?: unknown } = {}, + on409?: (body: string) => EdgeVcFailure | null, +): Promise> { + let response: { status: number; body: string } | null + + try { + response = await edgeAuthedRequest(path, { ...init, timeoutMs: VC_TIMEOUT_MS }) + } catch (error) { + // Rejection from `edgeAuthedRequest` means no answer at all — see edge-http's + // contract. This is the one branch that must not be reported as a denial. + return { + ok: false, + failure: { kind: 'unreachable', message: error instanceof Error ? error.message : 'No answer' }, + } + } + + if (!response) { + return { ok: false, failure: { kind: 'signed-out' } } + } + + const { status, body } = response + + if (status === 401 || status === 403) { + // 401 survived a renewal attempt inside `edgeAuthedRequest`, so it is a real + // authorization failure. 403 is a project the account may read but not write. + return status === 401 + ? { ok: false, failure: { kind: 'signed-out' } } + : { ok: false, failure: { kind: 'http', status, message: messageFromBody(body, status) } } + } + + if (status === 409 && on409) { + const failure = on409(body) + + if (failure) { + return { ok: false, failure } + } + } + + if (status >= 400) { + return { ok: false, failure: { kind: 'http', status, message: messageFromBody(body, status) } } + } + + const envelope = parseJsonBody>(body) + + if (!envelope || envelope.data === undefined) { + // A 2xx whose body we cannot read is not a success we can hand to the UI. + return { + ok: false, + failure: { kind: 'http', status, message: 'Autonomy Edge returned an unreadable response.' }, + } + } + + return { ok: true, data: envelope.data } +} + +/** For the routes whose answer the caller ignores (delete, discard, drop). */ +async function callVoid( + path: string, + init: { method?: 'GET' | 'POST' | 'DELETE'; json?: unknown } = {}, + on409?: (body: string) => EdgeVcFailure | null, +): Promise> { + const result = await call(path, init, on409) + + // These routes may answer 204, or 200 with no `data`. Both are success, so the + // unreadable-body check in `call` has to be relaxed for them rather than turning an + // empty success into an error. + if (!result.ok && result.failure.kind === 'http' && result.failure.status < 400) { + return { ok: true, data: null } + } + + return result.ok ? { ok: true, data: null } : result +} + +/** + * The carry rejection. The 409 body sits at the TOP level, not inside `data` — matching + * how the web adapter reads `error.response.data`. `hasConflicts` is what distinguishes a + * blocked carry from any other conflict on the same route. + */ +function carryConflict(body: string): EdgeVcFailure | null { + const payload = parseJsonBody<{ hasConflicts?: boolean; conflictedFiles?: string[] }>(body) + + return payload?.hasConflicts ? { kind: 'carry-conflict', conflictedFiles: payload.conflictedFiles ?? [] } : null +} + +/** + * The merge refusal. Same top-level body shape as the carry rejection, and the same + * discriminator: only `hasConflicts` means "decide per file", so any other 409 on the + * route stays an ordinary failure. + */ +function mergeConflict(body: string): EdgeVcFailure | null { + const payload = parseJsonBody<{ hasConflicts?: boolean; conflictedFiles?: string[]; message?: string }>(body) + + return payload?.hasConflicts + ? { + kind: 'merge-conflict', + conflictedFiles: payload.conflictedFiles ?? [], + message: payload.message ?? 'The merge has conflicts that need resolving', + } + : null +} + +/** Apply and pop answer 409 when the stash will not go on cleanly. */ +function stashConflict(): EdgeVcFailure { + return { kind: 'stash-conflict' } +} + +// --------------------------------------------------------------------------- +// Branches +// --------------------------------------------------------------------------- + +export function listBranches(projectId: string) { + return call<{ branches: unknown[] }>(`/projects/${projectId}/branches`) +} + +export function createBranch(projectId: string, name: string) { + return call<{ branch: unknown }>(`/projects/${projectId}/branches`, { method: 'POST', json: { name } }) +} + +export function deleteBranch(projectId: string, branchId: string) { + return callVoid(`/projects/${projectId}/branches/${branchId}`, { method: 'DELETE' }) +} + +export function switchBranch(projectId: string, branchName: string, strategy: 'discard' | 'carry') { + return call<{ message: string; branch: string }>( + `/projects/${projectId}/branches/switch`, + { method: 'POST', json: { branchName, strategy } }, + carryConflict, + ) +} + +export function previewSwitchCarry(projectId: string, targetBranch: string) { + const params = new URLSearchParams({ targetBranch }) + + return call<{ conflicts: string[] }>(`/projects/${projectId}/branches/preview-switch-carry?${params}`) +} + +// --------------------------------------------------------------------------- +// Commits +// --------------------------------------------------------------------------- + +export function listCommits(projectId: string, options: { limit?: number; offset?: number; branch?: string } = {}) { + const params = new URLSearchParams() + + if (options.limit !== undefined) params.set('limit', String(options.limit)) + if (options.offset !== undefined) params.set('offset', String(options.offset)) + if (options.branch) params.set('branch', options.branch) + + const query = params.toString() + + return call<{ commits: unknown[]; total: number; page: number }>( + `/projects/${projectId}/commits${query ? `?${query}` : ''}`, + ) +} + +export function createCommit(projectId: string, message: string, files?: string[], branch?: string) { + const json: Record = { message } + + if (files) json.files = files + if (branch) json.branch = branch + + return call(`/projects/${projectId}/commits`, { method: 'POST', json }) +} + +export function getCommitFiles(projectId: string, hash: string, branch?: string) { + const params = branch ? `?branch=${encodeURIComponent(branch)}` : '' + + return call<{ files: unknown[]; parentFiles: unknown[]; commit: unknown }>( + `/projects/${projectId}/commits/${hash}/files${params}`, + ) +} + +export function restoreCommit(projectId: string, hash: string, branch?: string) { + const json: Record = {} + + if (branch) json.branch = branch + + return call<{ message: string; restoredCommit: unknown }>(`/projects/${projectId}/commits/${hash}/restore`, { + method: 'POST', + json, + }) +} + +// --------------------------------------------------------------------------- +// Working tree +// --------------------------------------------------------------------------- + +export function getChanges(projectId: string, includeContent?: boolean) { + // No `branch` param, deliberately: the backend's validation whitelist rejects unknown + // query params, and pending changes are always computed against the worker's checked-out + // HEAD anyway. Sending it produces a 400 and nothing else. Same omission the web adapter + // documents. + const search = new URLSearchParams() + + if (includeContent) search.set('includeContent', 'true') + + const query = search.toString() + + return call<{ changes: unknown[]; hasChanges: boolean }>(`/projects/${projectId}/changes${query ? `?${query}` : ''}`) +} + +export function discardChanges(projectId: string, files?: string[]) { + // `branch` omitted for the same reason as `getChanges`. + const json: Record = {} + + if (files) json.files = files + + return callVoid(`/projects/${projectId}/discard-changes`, { method: 'POST', json }) +} + +// --------------------------------------------------------------------------- +// Stashes +// --------------------------------------------------------------------------- + +export function listStashes(projectId: string) { + return call<{ stashes: unknown[] }>(`/projects/${projectId}/stashes`) +} + +export function createStash(projectId: string, message?: string, files?: string[]) { + const json: Record = {} + + if (message) json.message = message + if (files && files.length > 0) json.files = files + + return call<{ stash: unknown }>(`/projects/${projectId}/stashes`, { method: 'POST', json }) +} + +export function applyStash(projectId: string, ref: string) { + return call<{ message: string }>( + `/projects/${projectId}/stashes/apply`, + { method: 'POST', json: { ref } }, + stashConflict, + ) +} + +export function popStash(projectId: string, ref: string) { + return call<{ message: string }>( + `/projects/${projectId}/stashes/pop`, + { method: 'POST', json: { ref } }, + stashConflict, + ) +} + +export function dropStash(projectId: string, ref: string) { + return callVoid(`/projects/${projectId}/stashes/drop`, { method: 'POST', json: { ref } }) +} + +// --------------------------------------------------------------------------- +// Merging +// --------------------------------------------------------------------------- + +export function getBranchDiffWithBase(projectId: string, source: string, target: string) { + const params = new URLSearchParams({ source, target }) + + return call(`/projects/${projectId}/branches-diff-with-base?${params}`) +} + +/** + * A merge is the one call here that can take real time: the server walks three trees and + * writes a commit. It gets the same 30s budget as the rest, which matches the web build. + * + * `mergeConflict` is what turns the 409 into its own kind, so the renderer can rebuild + * `MergeConflictError` and open the resolver instead of reporting a failure. + */ +export function mergeBranches(params: { + projectId: string + sourceBranch: string + targetBranch: string + commitMessage?: string + resolutions?: Record +}) { + const json: Record = { + sourceBranch: params.sourceBranch, + targetBranch: params.targetBranch, + } + + if (params.commitMessage) json.commitMessage = params.commitMessage + if (params.resolutions) json.resolutions = params.resolutions + + return call(`/projects/${params.projectId}/branches/merge`, { method: 'POST', json }, mergeConflict) +} diff --git a/src/backend/shared/project/__tests__/api-envelope.test.ts b/src/backend/shared/project/__tests__/api-envelope.test.ts new file mode 100644 index 000000000..cf7d6cb0e --- /dev/null +++ b/src/backend/shared/project/__tests__/api-envelope.test.ts @@ -0,0 +1,435 @@ +/** + * Tests for the Edge API envelope helpers. + * + * These functions own the web-only path↔slot mapping. Both the + * full-project saveProject path (`envelopeFromWriteProjectFiles` -> + * POST) and the single-file saveFile path (`getInEnvelope` / + * `setInEnvelope` for load-patch-save) dispatch through them, so + * the same cases must round-trip cleanly in both directions. + */ + +import type { WriteProjectFiles } from '../../../../middleware/shared/ports/project-port' +import { type ApiProjectFiles, envelopeFromWriteProjectFiles, getInEnvelope, setInEnvelope } from '../api-envelope' + +function makeEnvelope(overrides?: Partial): ApiProjectFiles { + return { + 'project.json': '{}', + devices: {} as ApiProjectFiles['devices'], + pous: {}, + ...overrides, + } +} + +/** + * `devices` is a flat map of file contents that also carries one nested slot, + * and no object literal satisfies both at once: the index signature demands a + * string for every key while `remote` is a map. Filling the slot after the fact + * builds the value the API actually sends without loosening the type. + */ +const devicesWithRemote = (remote: Record): ApiProjectFiles['devices'] => { + const devices: ApiProjectFiles['devices'] = {} + devices.remote = remote + return devices +} + +describe('getInEnvelope', () => { + it('reads project.json at the root', () => { + const env = makeEnvelope({ 'project.json': '{"name":"x"}' }) + expect(getInEnvelope(env, 'project.json')).toBe('{"name":"x"}') + }) + + it('reads library.json at the root when present', () => { + const env = makeEnvelope({ 'library.json': '{"name":"mylib"}' }) + expect(getInEnvelope(env, 'library.json')).toBe('{"name":"mylib"}') + }) + + it('returns undefined for library.json when absent (PLC project)', () => { + expect(getInEnvelope(makeEnvelope(), 'library.json')).toBeUndefined() + }) + + it('reads devices/configuration.json from envelope.devices', () => { + const env = makeEnvelope({ + devices: { 'configuration.json': '{"board":"uno"}' } as ApiProjectFiles['devices'], + }) + expect(getInEnvelope(env, 'devices/configuration.json')).toBe('{"board":"uno"}') + }) + + it('reads devices/pin-mapping.json from envelope.devices', () => { + const env = makeEnvelope({ + devices: { 'pin-mapping.json': '[]' } as ApiProjectFiles['devices'], + }) + expect(getInEnvelope(env, 'devices/pin-mapping.json')).toBe('[]') + }) + + it('reads devices/remote/* from envelope.devices.remote', () => { + const env = makeEnvelope({ + devices: devicesWithRemote({ 'bus0.json': '{"id":0}' }), + }) + expect(getInEnvelope(env, 'devices/remote/bus0.json')).toBe('{"id":0}') + }) + + it('reads devices/servers/* from envelope.servers', () => { + const env = makeEnvelope({ servers: { 'modbus.json': '{"port":502}' } }) + expect(getInEnvelope(env, 'devices/servers/modbus.json')).toBe('{"port":502}') + }) + + it('reads pous/{category}/{filename} from envelope.pous', () => { + const env = makeEnvelope({ + pous: { + programs: { 'main.st': 'PROGRAM main' }, + 'function-blocks': { 'timer.st': 'FB timer' }, + }, + }) + expect(getInEnvelope(env, 'pous/programs/main.st')).toBe('PROGRAM main') + expect(getInEnvelope(env, 'pous/function-blocks/timer.st')).toBe('FB timer') + }) + + it('returns undefined for unknown paths', () => { + expect(getInEnvelope(makeEnvelope(), 'unknown/path')).toBeUndefined() + expect(getInEnvelope(makeEnvelope(), 'devices/unknown')).toBeUndefined() + expect(getInEnvelope(makeEnvelope(), 'pous/programs')).toBeUndefined() + }) + + it('returns undefined when intermediate containers are missing', () => { + const env = makeEnvelope() + expect(getInEnvelope(env, 'devices/remote/anything.json')).toBeUndefined() + expect(getInEnvelope(env, 'devices/servers/anything.json')).toBeUndefined() + expect(getInEnvelope(env, 'pous/programs/missing.st')).toBeUndefined() + }) +}) + +describe('setInEnvelope', () => { + it('writes project.json at the root', () => { + const env = makeEnvelope() + setInEnvelope(env, 'project.json', '{"name":"x"}') + expect(env['project.json']).toBe('{"name":"x"}') + }) + + it('writes library.json at the root', () => { + const env = makeEnvelope() + setInEnvelope(env, 'library.json', '{"name":"mylib","version":"0.1.0"}') + expect(env['library.json']).toBe('{"name":"mylib","version":"0.1.0"}') + }) + + it('writes devices/configuration.json', () => { + const env = makeEnvelope() + setInEnvelope(env, 'devices/configuration.json', '{"board":"uno"}') + expect(env.devices['configuration.json']).toBe('{"board":"uno"}') + }) + + it('writes devices/pin-mapping.json', () => { + const env = makeEnvelope() + setInEnvelope(env, 'devices/pin-mapping.json', '[]') + expect(env.devices['pin-mapping.json']).toBe('[]') + }) + + it('lazily initialises devices.remote container when writing first remote device', () => { + const env = makeEnvelope() + setInEnvelope(env, 'devices/remote/bus0.json', '{"id":0}') + expect(env.devices.remote).toEqual({ 'bus0.json': '{"id":0}' }) + }) + + it('appends to existing devices.remote', () => { + const env = makeEnvelope({ + devices: devicesWithRemote({ 'bus0.json': '{"id":0}' }), + }) + setInEnvelope(env, 'devices/remote/bus1.json', '{"id":1}') + expect(env.devices.remote).toEqual({ 'bus0.json': '{"id":0}', 'bus1.json': '{"id":1}' }) + }) + + it('lazily initialises envelope.servers container when writing first server', () => { + const env = makeEnvelope() + setInEnvelope(env, 'devices/servers/modbus.json', '{"port":502}') + expect(env.servers).toEqual({ 'modbus.json': '{"port":502}' }) + }) + + it('appends to existing envelope.servers', () => { + const env = makeEnvelope({ servers: { 'modbus.json': '{"port":502}' } }) + setInEnvelope(env, 'devices/servers/opcua.json', '{"port":4840}') + expect(env.servers).toEqual({ 'modbus.json': '{"port":502}', 'opcua.json': '{"port":4840}' }) + }) + + it('lazily initialises envelope.pous[category] when writing first POU of that category', () => { + const env = makeEnvelope() + setInEnvelope(env, 'pous/programs/main.st', 'PROGRAM main') + expect(env.pous.programs).toEqual({ 'main.st': 'PROGRAM main' }) + }) + + it('handles all three POU categories', () => { + const env = makeEnvelope() + setInEnvelope(env, 'pous/programs/main.st', 'P') + setInEnvelope(env, 'pous/functions/add.st', 'F') + setInEnvelope(env, 'pous/function-blocks/timer.st', 'FB') + expect(env.pous).toEqual({ + programs: { 'main.st': 'P' }, + functions: { 'add.st': 'F' }, + 'function-blocks': { 'timer.st': 'FB' }, + }) + }) + + it('is idempotent for the same path+content', () => { + const env = makeEnvelope() + setInEnvelope(env, 'project.json', 'X') + setInEnvelope(env, 'project.json', 'X') + expect(env['project.json']).toBe('X') + }) + + it('overwrites existing content', () => { + const env = makeEnvelope({ 'project.json': 'OLD' }) + setInEnvelope(env, 'project.json', 'NEW') + expect(env['project.json']).toBe('NEW') + }) + + it('silently no-ops on unknown paths', () => { + const env = makeEnvelope() + const snapshot = JSON.stringify(env) + setInEnvelope(env, 'unknown/path/file', 'x') + setInEnvelope(env, 'pous/programs', 'x') // wrong arity + setInEnvelope(env, 'devices/random', 'x') // unknown sub-key + setInEnvelope(env, 'pous/programs/nested/too/deep', 'x') // too many parts + expect(JSON.stringify(env)).toBe(snapshot) + }) + + it('lazily initialises envelope.build container when writing first build artifact', () => { + const env = makeEnvelope() + setInEnvelope(env, 'build/test-lib.stlib', '{"manifest":{}}') + expect(env.build).toEqual({ 'test-lib.stlib': '{"manifest":{}}' }) + }) + + it('appends to existing envelope.build', () => { + const env = makeEnvelope({ build: { 'first.stlib': '{}' } }) + setInEnvelope(env, 'build/.verify-cache-library.json', '{"md5":"x"}') + expect(env.build).toEqual({ + 'first.stlib': '{}', + '.verify-cache-library.json': '{"md5":"x"}', + }) + }) + + it('round-trips via getInEnvelope for every supported category', () => { + const env = makeEnvelope() + const cases: Array<[string, string]> = [ + ['project.json', 'PJ'], + ['library.json', 'LIB'], + ['devices/configuration.json', 'DC'], + ['devices/pin-mapping.json', 'PM'], + ['devices/remote/bus.json', 'RD'], + ['devices/servers/srv.json', 'SV'], + ['pous/programs/main.st', 'PG'], + ['pous/functions/add.st', 'FN'], + ['pous/function-blocks/tmr.st', 'FB'], + ['datatypes/Motor.dt', 'DT'], + ['build/lib.stlib', 'STLIB'], + ['build/.verify-cache-library.json', 'CACHE'], + ] + for (const [path, content] of cases) { + setInEnvelope(env, path, content) + } + for (const [path, content] of cases) { + expect(getInEnvelope(env, path)).toBe(content) + } + }) +}) + +/** + * The envelope a brand-new project actually comes back with. + * + * `GET /projects/:id/details` answers `files: {}` for a project that has never + * been saved — no `pous`, no `devices`, not even `project.json`. `makeEnvelope` + * above always supplies those containers, which is exactly why this went + * unnoticed: `setInEnvelope` assumed they existed and threw a TypeError, so + * `saveFile`'s load-patch-save round trip failed between the GET and the POST. + * Ctrl+S issued the read, died on the patch, never wrote anything, and left the + * file dirty behind a toast that faded. Full project saves were fine because + * they build a complete envelope from scratch. + */ +describe('setInEnvelope on the envelope a new project really returns', () => { + /** `files: {}` — no containers at all, as the API sends it. */ + function emptyEnvelope(): ApiProjectFiles { + return {} as unknown as ApiProjectFiles + } + + it('writes a POU without a pous container', () => { + const env = emptyEnvelope() + + setInEnvelope(env, 'pous/programs/main.st', 'PROGRAM main END_PROGRAM') + + expect(env.pous).toEqual({ programs: { 'main.st': 'PROGRAM main END_PROGRAM' } }) + }) + + it('writes the device config without a devices container', () => { + const env = emptyEnvelope() + + setInEnvelope(env, 'devices/configuration.json', '{"board":"uno"}') + + expect(env.devices['configuration.json']).toBe('{"board":"uno"}') + }) + + it('writes the pin mapping without a devices container', () => { + const env = emptyEnvelope() + + setInEnvelope(env, 'devices/pin-mapping.json', '[]') + + expect(env.devices['pin-mapping.json']).toBe('[]') + }) + + it('writes a remote device without a devices container', () => { + const env = emptyEnvelope() + + setInEnvelope(env, 'devices/remote/bus0.json', '{"id":0}') + + expect(env.devices.remote).toEqual({ 'bus0.json': '{"id":0}' }) + }) + + it('writes project.json without any containers', () => { + const env = emptyEnvelope() + + setInEnvelope(env, 'project.json', '{"meta":{"name":"P"}}') + + expect(env['project.json']).toBe('{"meta":{"name":"P"}}') + }) + + // What the crash cost: the write never reached the transport at all. + it('never throws, whatever container is missing', () => { + const paths = [ + 'project.json', + 'library.json', + 'devices/configuration.json', + 'devices/pin-mapping.json', + 'devices/remote/bus0.json', + 'devices/servers/opcua.json', + 'pous/programs/main.st', + 'pous/functions/f.st', + 'datatypes/MyType.dt', + 'build/lib.stlib', + 'totally/unknown/path.txt', + ] + + for (const path of paths) { + expect(() => setInEnvelope(emptyEnvelope(), path, 'X')).not.toThrow() + } + }) + + // Symmetry with getInEnvelope is the actual invariant: what one writes into a + // bare envelope, the other has to be able to read back out. + it('round-trips through getInEnvelope from a bare envelope', () => { + const env = emptyEnvelope() + + setInEnvelope(env, 'pous/programs/main.st', 'CODE') + setInEnvelope(env, 'devices/configuration.json', '{"board":"uno"}') + setInEnvelope(env, 'devices/remote/bus0.json', '{"id":0}') + + expect(getInEnvelope(env, 'pous/programs/main.st')).toBe('CODE') + expect(getInEnvelope(env, 'devices/configuration.json')).toBe('{"board":"uno"}') + expect(getInEnvelope(env, 'devices/remote/bus0.json')).toBe('{"id":0}') + }) + + // A patch must not invent files the project does not have; the backend + // deletes anything missing from the payload. + it('adds only the container the path needs', () => { + const env = emptyEnvelope() + + setInEnvelope(env, 'pous/programs/main.st', 'CODE') + + expect(Object.keys(env)).toEqual(['pous']) + }) +}) + +describe('envelopeFromWriteProjectFiles', () => { + function makeWriteFiles(overrides?: Partial): WriteProjectFiles { + return { + projectPath: 'proj-1', + projectJson: '{"meta":{}}', + pouFiles: [], + serverFiles: [], + remoteDeviceFiles: [], + dataTypeFiles: [], + deletions: [], + ...overrides, + } + } + + it('produces a minimal envelope with only project.json from an empty WriteProjectFiles', () => { + const env = envelopeFromWriteProjectFiles(makeWriteFiles()) + expect(env).toEqual({ + 'project.json': '{"meta":{}}', + devices: {}, + pous: {}, + }) + }) + + it('PLC project: emits project.json, devices.configuration, devices.pin-mapping, pous, servers', () => { + const env = envelopeFromWriteProjectFiles( + makeWriteFiles({ + deviceConfig: '{"board":"uno"}', + pinMapping: '[]', + pouFiles: [{ relativePath: 'pous/programs/main.st', content: 'PROGRAM main' }], + serverFiles: [{ relativePath: 'devices/servers/modbus.json', content: '{"port":502}' }], + remoteDeviceFiles: [{ relativePath: 'devices/remote/bus0.json', content: '{"id":0}' }], + }), + ) + expect(env).toEqual({ + 'project.json': '{"meta":{}}', + devices: { + 'configuration.json': '{"board":"uno"}', + 'pin-mapping.json': '[]', + remote: { 'bus0.json': '{"id":0}' }, + }, + pous: { programs: { 'main.st': 'PROGRAM main' } }, + servers: { 'modbus.json': '{"port":502}' }, + }) + }) + + it('library project: emits project.json + library.json, no device files, no servers', () => { + const env = envelopeFromWriteProjectFiles( + makeWriteFiles({ + libraryManifest: '{"name":"mylib","version":"0.1.0"}', + pouFiles: [{ relativePath: 'pous/functions/add.st', content: 'FUNCTION add' }], + }), + ) + expect(env).toEqual({ + 'project.json': '{"meta":{}}', + 'library.json': '{"name":"mylib","version":"0.1.0"}', + devices: {}, + pous: { functions: { 'add.st': 'FUNCTION add' } }, + }) + }) + + it('omits library.json when libraryManifest is undefined', () => { + const env = envelopeFromWriteProjectFiles(makeWriteFiles()) + expect(env['library.json']).toBeUndefined() + }) + + it('slots data type files under envelope.datatypes and omits the container when empty', () => { + const env = envelopeFromWriteProjectFiles( + makeWriteFiles({ + dataTypeFiles: [ + { relativePath: 'datatypes/Motor.dt', content: 'TYPE\n Motor : STRUCT\n END_STRUCT;\nEND_TYPE\n' }, + { relativePath: 'datatypes/Color.dt', content: 'TYPE\n Color : (Red);\nEND_TYPE\n' }, + ], + }), + ) + expect(env.datatypes).toEqual({ + 'Motor.dt': 'TYPE\n Motor : STRUCT\n END_STRUCT;\nEND_TYPE\n', + 'Color.dt': 'TYPE\n Color : (Red);\nEND_TYPE\n', + }) + expect(envelopeFromWriteProjectFiles(makeWriteFiles()).datatypes).toBeUndefined() + }) + + it('groups multiple POUs by category', () => { + const env = envelopeFromWriteProjectFiles( + makeWriteFiles({ + pouFiles: [ + { relativePath: 'pous/programs/a.st', content: 'A' }, + { relativePath: 'pous/programs/b.st', content: 'B' }, + { relativePath: 'pous/functions/c.st', content: 'C' }, + { relativePath: 'pous/function-blocks/d.st', content: 'D' }, + ], + }), + ) + expect(env.pous).toEqual({ + programs: { 'a.st': 'A', 'b.st': 'B' }, + functions: { 'c.st': 'C' }, + 'function-blocks': { 'd.st': 'D' }, + }) + }) +}) diff --git a/src/backend/shared/project/api-envelope.ts b/src/backend/shared/project/api-envelope.ts new file mode 100644 index 000000000..6bc5078af --- /dev/null +++ b/src/backend/shared/project/api-envelope.ts @@ -0,0 +1,255 @@ +/** + * Web-only Edge API envelope shape and the canonical path↔slot + * mapping that goes with it. + * + * The Edge API stores projects as a nested JSON object (top-level + * `project.json`, `library.json`, `devices/{...}`, `pous/{cat}/{file}`, + * `servers/{file}`). The backend's `flattenFileHierarchy` walks + * this shape and lands each leaf at its `relativePath` on S3. + * The shape is web-specific — the editor writes the same files + * straight to disk and doesn't need an envelope at all — so this + * module lives in the web adapter, not in `backend/shared`. + * + * `getInEnvelope` / `setInEnvelope` are the symmetric pair that + * own the path→slot mapping; both `saveProject` (full-snapshot + * write) and `saveFile` (single-slot patch) dispatch through them + * so a future file category added to `iterateWriteProjectFiles` + * cannot drift on one side without TypeScript flagging the other. + * `envelopeFromWriteProjectFiles` walks the shared iterator and + * calls `setInEnvelope` for each entry — the iterator decides + * "which files exist", this helper decides "where they go". + */ + +import type { WriteProjectFiles } from '../../../middleware/shared/ports/project-port' +import { iterateWriteProjectFiles } from './iterate-write-project-files' + +/** + * Shape the Edge API uses for project file payloads. Optional + * top-level keys (`library.json`, `servers`) are present only when + * the project owns those files; the canonical `findInEnvelope` / + * `setInEnvelope` pair handles the missing-container case. + */ +export interface ApiProjectFiles { + 'project.json': string + /** Library projects only. Absent on PLC projects. */ + 'library.json'?: string + /** + * Raw PLCopen XML marker written by Node's raw-import path + * (`plcopen-pending-import.xml` at the project root). Present ONLY on a + * project that's pending conversion — Node stores the uploaded XML + * verbatim and does no server-side parsing, so a project in this state + * has no `project.json` and no `pous`. Absent on every normal project. + */ + 'plcopen-pending-import.xml'?: string + devices: Record & { + /** Nested map for `devices/remote/*` files; absent when empty. */ + remote?: Record + } + /** Nested map: `pous[category][filename]`. */ + pous: Record> + /** Flat map for `datatypes/*.dt` files; absent when the project + * has no data-type files (predates the format or has no types). */ + datatypes?: Record + /** Flat map for `devices/servers/*` files; absent when empty. */ + servers?: Record + /** + * Build artifacts: the compiled `.stlib` for library projects + * and the verification cache (`.verify-cache-library.json`). The + * shared library-build orchestrator writes both through + * `setInEnvelope('build/')`, so the slot must exist here for + * the writes to round-trip the save endpoint instead of being + * silently dropped by `setInEnvelope`'s unknown-path branch. + */ + build?: Record +} + +/** + * Look up a single file's content by its project-root-relative path. + * Returns `undefined` when the envelope doesn't carry the file + * (e.g. PLC project's `library.json`) OR when the path is unknown. + * Callers distinguishing "missing" from "unknown path" should + * validate the path shape themselves; the contract here is the + * superset of both. + */ +export function getInEnvelope(env: ApiProjectFiles, relativePath: string): string | undefined { + if (relativePath === 'project.json') return env['project.json'] + if (relativePath === 'library.json') return env['library.json'] + if (relativePath === 'devices/configuration.json') return env.devices?.['configuration.json'] + if (relativePath === 'devices/pin-mapping.json') return env.devices?.['pin-mapping.json'] + + const parts = relativePath.split('/') + if (parts.length === 3 && parts[0] === 'devices' && parts[1] === 'remote') { + return env.devices?.remote?.[parts[2]] + } + if (parts.length === 3 && parts[0] === 'devices' && parts[1] === 'servers') { + return env.servers?.[parts[2]] + } + if (parts.length === 3 && parts[0] === 'pous') { + return env.pous?.[parts[1]]?.[parts[2]] + } + if (parts.length === 2 && parts[0] === 'datatypes') { + return env.datatypes?.[parts[1]] + } + // Flat `build/`: `build/.stlib` and + // `build/.verify-cache-library.json`. Nested build paths (e.g. + // `build/library/src/plc.xml`) are intentionally not persisted by + // the orchestrator and are dropped here for symmetry — see the + // path-constants comment in library-build-orchestrator.ts. + if (parts.length === 2 && parts[0] === 'build') { + return env.build?.[parts[1]] + } + return undefined +} + +/** + * Patch the envelope so the slot for `relativePath` carries `content`. + * Creates every container on the way in — the top-level `env.devices` / + * `env.pous` maps as well as the nested `env.devices.remote`, + * `env.servers`, `env.pous[category]`. No-op for unknown paths so + * callers can blindly forward an iterator's output without a path + * allowlist — unknown categories simply fall through. + * + * Creating the TOP-LEVEL containers is load-bearing, not defensive + * tidiness. `getInEnvelope` guards every container with `?.` because the + * API omits a container the project has no files for; this function used + * to assume `env.devices` and `env.pous` were always objects and threw a + * TypeError when they were not. A brand-new project's `/details` answers + * `files: {}` — no `pous`, no `devices`, not even `project.json` — so + * `saveFile`'s load-patch-save round trip died on the patch, returned a + * failure, and Ctrl+S did nothing but flash a toast: the GET went out, the + * POST never did, and the file stayed dirty with no explanation. Full + * project saves were unaffected because `envelopeFromWriteProjectFiles` + * starts from a complete literal, which is why this only bit the + * single-file path and only until the first full save. + * + * Mutates `env` in place. Idempotent for the same `(path, content)`. + */ +export function setInEnvelope(env: ApiProjectFiles, relativePath: string, content: string): void { + if (relativePath === 'project.json') { + env['project.json'] = content + return + } + if (relativePath === 'library.json') { + env['library.json'] = content + return + } + if (relativePath === 'devices/configuration.json') { + if (!env.devices) env.devices = {} + env.devices['configuration.json'] = content + return + } + if (relativePath === 'devices/pin-mapping.json') { + if (!env.devices) env.devices = {} + env.devices['pin-mapping.json'] = content + return + } + + const parts = relativePath.split('/') + if (parts.length === 3 && parts[0] === 'devices' && parts[1] === 'remote') { + if (!env.devices) env.devices = {} + if (!env.devices.remote) env.devices.remote = {} + env.devices.remote[parts[2]] = content + return + } + if (parts.length === 3 && parts[0] === 'devices' && parts[1] === 'servers') { + if (!env.servers) env.servers = {} + env.servers[parts[2]] = content + return + } + if (parts.length === 3 && parts[0] === 'pous') { + if (!env.pous) env.pous = {} + if (!env.pous[parts[1]]) env.pous[parts[1]] = {} + env.pous[parts[1]][parts[2]] = content + return + } + if (parts.length === 2 && parts[0] === 'datatypes') { + if (!env.datatypes) env.datatypes = {} + env.datatypes[parts[1]] = content + return + } + // Flat `build/`. See the matching branch in + // `getInEnvelope` for the rationale; this lets the library-build + // orchestrator's `.stlib` write + verification-cache write round- + // trip the save endpoint instead of being silently dropped here. + if (parts.length === 2 && parts[0] === 'build') { + if (!env.build) env.build = {} + env.build[parts[1]] = content + return + } + // Unknown path — silently ignored. Iterator output stays in sync + // with this mapping; an iterator change that adds a new category + // without updating this function would surface as "envelope is + // missing the file" in integration tests rather than crashing here. +} + +/** + * Build a fresh envelope from a flat `WriteProjectFiles`. Iterates + * the shared generator and slots each entry; both sides — what + * files exist, where they go — are now expressed in one place + * (iterator + envelope mapping), nowhere does the project-adapter + * hand-roll the envelope shape. + */ +export function envelopeFromWriteProjectFiles(files: WriteProjectFiles): ApiProjectFiles { + const env: ApiProjectFiles = { + 'project.json': '', + devices: {} as ApiProjectFiles['devices'], + pous: {}, + } + for (const entry of iterateWriteProjectFiles(files)) { + setInEnvelope(env, entry.relativePath, entry.content) + } + return env +} + +/** + * Envelope -> the shape a project reader hands back. + * + * The inverse of `envelopeFromWriteProjectFiles`, and it lives beside it for that + * reason: the two describe one wire format, and a change to either that is not + * mirrored in the other corrupts a round trip. It used to live in the web adapter, + * which meant the desktop editor could not read a cloud project without a second + * copy of the same knowledge. + */ +export function apiFilesToRaw(projectPath: string, files: ApiProjectFiles) { + const pouFiles = [] + for (const [category, categoryFiles] of Object.entries(files.pous ?? {})) { + for (const [filename, content] of Object.entries(categoryFiles)) { + pouFiles.push({ relativePath: `pous/${category}/${filename}`, content }) + } + } + const serverFiles = [] + for (const [filename, content] of Object.entries(files.servers ?? {})) { + serverFiles.push({ relativePath: `devices/servers/${filename}`, content }) + } + const remoteDeviceFiles = [] + for (const [filename, content] of Object.entries(files.devices?.remote ?? {})) { + remoteDeviceFiles.push({ relativePath: `devices/remote/${filename}`, content }) + } + const dataTypeFiles = [] + for (const [filename, content] of Object.entries(files.datatypes ?? {})) { + dataTypeFiles.push({ relativePath: `datatypes/${filename}`, content }) + } + return { + projectPath, + projectJson: files['project.json'], + deviceConfig: files.devices?.['configuration.json'] ?? '{}', + pinMapping: files.devices?.['pin-mapping.json'] ?? '[]', + // Empty string when the API doesn't carry a `library.json` + // (PLC projects don't have one; library projects do). The + // shared `RawProjectFiles` contract makes this field + // non-optional so PLC-vs-library callers don't have to + // special-case its presence — empty string is the documented + // sentinel. When the web backend adds library-project support + // it can surface `files['library.json']` and the same shape + // continues to work. + libraryManifest: files['library.json'] ?? '', + pouFiles, + serverFiles, + remoteDeviceFiles, + dataTypeFiles, + // `undefined` when absent — that's the "not a pending PLCopen import" + // case. Present only when Node's project directory is a bare + // `plcopen-pending-import.xml` marker (see openProjectByPath). + pendingPlcopenSource: files['plcopen-pending-import.xml'], + } +} diff --git a/src/backend/shared/utils/__tests__/graphical-diff.test.ts b/src/backend/shared/utils/__tests__/graphical-diff.test.ts new file mode 100644 index 000000000..b08f4d1e6 --- /dev/null +++ b/src/backend/shared/utils/__tests__/graphical-diff.test.ts @@ -0,0 +1,255 @@ +import { computeGraphicalDiff } from '@root/backend/shared/utils/graphical-diff' +import type { Edge, Node } from '@xyflow/react' + +type Rung = { id?: string; nodes: Node[]; edges?: Edge[] } + +const rail = (id: string): Node => ({ id, type: 'powerRail', position: { x: 0, y: 50 }, data: {} }) + +const contact = (id: string, name: string, x: number): Node => ({ + id, + type: 'contact', + position: { x, y: 50 }, + data: { variable: { name } }, +}) + +const coil = (id: string, name: string, x: number): Node => ({ + id, + type: 'coil', + position: { x, y: 50 }, + data: { variable: { name } }, +}) + +const edge = (source: string, target: string): Edge => ({ id: `e_${source}_${target}`, source, target }) + +const withBody = (body: unknown) => `PROGRAM main\nVAR\n A : BOOL;\nEND_VAR\n${JSON.stringify(body)}\nEND_PROGRAM` + +const ld = (rungs: Rung[]) => withBody({ rungs: rungs.map((r) => ({ edges: [], ...r })) }) +const fbd = (rung: Rung) => withBody({ rung: { edges: [], ...rung } }) + +const diffLd = (original: Rung[], current: Rung[]) => + computeGraphicalDiff(ld(original), ld(current), 'pous/programs/main.ld') + +describe('computeGraphicalDiff — node matching', () => { + it('leaves an untouched rung alone when another rung rebinds a same-key element (DOPE-496)', () => { + const head: Rung[] = [ + { id: 'r1', nodes: [rail('RAIL_1'), contact('NODE_c1', '', 100), coil('NODE_coil1', 'A', 300)] }, + { + id: 'r2', + nodes: [ + rail('RAIL_2'), + contact('NODE_c2L', '', 100), + contact('NODE_c2R', '', 200), + coil('NODE_coil2', 'B', 300), + ], + }, + ] + const work: Rung[] = [ + { id: 'r1', nodes: [rail('RAIL_1'), contact('NODE_c1', 'new', 100), coil('NODE_coil1', 'A', 300)] }, + head[1], + { id: 'r3', nodes: [rail('RAIL_3'), contact('NODE_c3', '', 100), coil('NODE_coil3', 'C', 300)] }, + ] + + const { nodeDiffMaps, changedIndexes } = diffLd(head, work) + + expect(nodeDiffMaps.current.get('NODE_c1')).toBe('modified') + expect(nodeDiffMaps.original.get('NODE_c1')).toBe('modified') + + for (const id of ['NODE_c2L', 'NODE_c2R', 'NODE_coil2']) { + expect(nodeDiffMaps.original.get(id)).toBe('unchanged') + expect(nodeDiffMaps.current.get(id)).toBe('unchanged') + } + + expect(nodeDiffMaps.current.get('NODE_c3')).toBe('added') + expect(nodeDiffMaps.current.get('NODE_coil3')).toBe('added') + expect(changedIndexes).toEqual([0, 2]) + }) + + it('keeps matches rung-local when an XML round trip regenerated the node ids', () => { + const head: Rung[] = [ + { id: 'r1', nodes: [contact('CONTACT-1', '', 100), coil('COIL-1', 'A', 300)] }, + { id: 'r2', nodes: [contact('CONTACT-2', '', 100), coil('COIL-2', 'B', 300)] }, + ] + const work: Rung[] = [ + { + id: 'r1', + nodes: [contact('CONTACT-1', 'new', 100), contact('CONTACT-2', '', 200), coil('COIL-1', 'A', 400)], + }, + { id: 'r2', nodes: [contact('CONTACT-3', '', 100), coil('COIL-2', 'B', 300)] }, + ] + + const { nodeDiffMaps, changedIndexes } = diffLd(head, work) + + expect(nodeDiffMaps.current.get('CONTACT-1')).toBe('modified') + expect(nodeDiffMaps.current.get('CONTACT-2')).toBe('added') + // rung 2's renumbered contact matches its own rung's contact, not rung 1's + expect(nodeDiffMaps.current.get('CONTACT-3')).toBe('unchanged') + expect(nodeDiffMaps.original.get('CONTACT-2')).toBe('unchanged') + expect(nodeDiffMaps.current.get('COIL-2')).toBe('unchanged') + expect(changedIndexes).toEqual([0]) + }) + + it('reports a deleted element as removed without stealing another rung nodes', () => { + const head: Rung[] = [ + { id: 'r1', nodes: [contact('NODE_c1', '', 100), coil('NODE_coil1', 'A', 300)] }, + { id: 'r2', nodes: [contact('NODE_c2L', '', 100), contact('NODE_c2R', '', 200), coil('NODE_coil2', 'B', 300)] }, + ] + const work: Rung[] = [head[0], { id: 'r2', nodes: [contact('NODE_c2L', '', 100), coil('NODE_coil2', 'B', 300)] }] + + const { nodeDiffMaps, changedIndexes } = diffLd(head, work) + + expect(nodeDiffMaps.original.get('NODE_c2R')).toBe('removed') + expect(nodeDiffMaps.original.get('NODE_c1')).toBe('unchanged') + expect(nodeDiffMaps.current.get('NODE_c1')).toBe('unchanged') + expect(changedIndexes).toEqual([1]) + }) + + it('recovers a node stranded in the wrong rung pair by id instead of add + remove', () => { + const head: Rung[] = [ + { + id: 'r1', + nodes: [contact('NODE_x', 'X', 100), coil('NODE_coil1', 'A', 300)], + edges: [edge('NODE_x', 'NODE_coil1')], + }, + { id: 'r2', nodes: [coil('NODE_coil2', 'B', 300)] }, + ] + const work: Rung[] = [ + { id: 'r1', nodes: [coil('NODE_coil1', 'A', 300)] }, + { + // x differs from r1's, so this fails if the cross-rung pass compares position. + id: 'r2', + nodes: [contact('NODE_x', 'X', 250), coil('NODE_coil2', 'B', 300)], + edges: [edge('NODE_x', 'NODE_coil2')], + }, + ] + + const { nodeDiffMaps, changedIndexes } = diffLd(head, work) + + expect(nodeDiffMaps.original.get('NODE_x')).toBe('unchanged') + expect(nodeDiffMaps.current.get('NODE_x')).toBe('unchanged') + // both rungs still read as changed, through their edges + expect(changedIndexes).toEqual([0, 1]) + }) + + it('marks structural nodes unchanged on both sides', () => { + const head: Rung[] = [{ id: 'r1', nodes: [rail('RAIL_1'), contact('NODE_c1', 'X', 100)] }] + const work: Rung[] = [{ id: 'r1', nodes: [rail('RAIL_1'), contact('NODE_c1', 'Y', 100)] }] + + const { nodeDiffMaps } = diffLd(head, work) + + expect(nodeDiffMaps.original.get('RAIL_1')).toBe('unchanged') + expect(nodeDiffMaps.current.get('RAIL_1')).toBe('unchanged') + }) +}) + +describe('computeGraphicalDiff — rung alignment', () => { + it('aligns rungs by id so an inserted rung does not shift the pairing', () => { + const head: Rung[] = [ + { id: 'r1', nodes: [contact('NODE_c1', '', 100), coil('NODE_coil1', 'A', 300)] }, + { id: 'r2', nodes: [contact('NODE_c2', '', 100), coil('NODE_coil2', 'B', 300)] }, + ] + const work: Rung[] = [{ id: 'rNEW', nodes: [contact('NODE_cN', '', 100), coil('NODE_coilN', 'Z', 300)] }, ...head] + + const { flows, changedIndexes, nodeDiffMaps } = diffLd(head, work) + + expect(flows).toHaveLength(3) + expect(flows[0].original).toBeNull() + expect(flows[0].current?.id).toBe('rNEW') + expect(flows[1].original?.id).toBe('r1') + expect(flows[1].current?.id).toBe('r1') + expect(flows[2].original?.id).toBe('r2') + expect(flows[2].current?.id).toBe('r2') + expect(changedIndexes).toEqual([0]) + expect(nodeDiffMaps.current.get('NODE_c2')).toBe('unchanged') + expect(nodeDiffMaps.current.get('NODE_cN')).toBe('added') + }) + + it('reports a rung deleted from the middle as a removed rung', () => { + const head: Rung[] = [ + { id: 'r1', nodes: [coil('NODE_coil1', 'A', 300)] }, + { id: 'r2', nodes: [coil('NODE_coil2', 'B', 300)] }, + { id: 'r3', nodes: [coil('NODE_coil3', 'C', 300)] }, + ] + const work: Rung[] = [head[0], head[2]] + + const { flows, changedIndexes, nodeDiffMaps } = diffLd(head, work) + + expect(flows).toHaveLength(3) + expect(flows[1].original?.id).toBe('r2') + expect(flows[1].current).toBeNull() + expect(changedIndexes).toEqual([1]) + expect(nodeDiffMaps.original.get('NODE_coil2')).toBe('removed') + expect(nodeDiffMaps.current.get('NODE_coil3')).toBe('unchanged') + }) + + it('falls back to positional pairing when rung ids are missing', () => { + const head: Rung[] = [{ nodes: [contact('NODE_c1', 'X', 100)] }, { nodes: [coil('NODE_coil2', 'B', 300)] }] + const work: Rung[] = [{ nodes: [contact('NODE_c1', 'Y', 100)] }, head[1]] + + const { flows, changedIndexes, nodeDiffMaps } = diffLd(head, work) + + expect(flows).toHaveLength(2) + expect(nodeDiffMaps.current.get('NODE_c1')).toBe('modified') + expect(changedIndexes).toEqual([0]) + }) + + it('falls back to positional pairing when the two sides share no rung id', () => { + const nodesA = [contact('NODE_c1', 'X', 100), coil('NODE_coil1', 'A', 300)] + const nodesB = [contact('NODE_c2', 'Y', 100), coil('NODE_coil2', 'B', 300)] + const head: Rung[] = [ + { id: 'uuid-aaa', nodes: nodesA }, + { id: 'uuid-bbb', nodes: nodesB }, + ] + const work: Rung[] = [ + { id: 'rung-0', nodes: nodesA }, + { id: 'rung-1', nodes: nodesB }, + ] + + const { flows, changedIndexes } = diffLd(head, work) + + expect(flows).toHaveLength(2) + expect(flows[0].original?.id).toBe('uuid-aaa') + expect(flows[0].current?.id).toBe('rung-0') + expect(changedIndexes).toEqual([]) + }) + + it('falls back to positional pairing above the alignable rung count', () => { + const mk = (count: number, prefix: string): Rung[] => + Array.from({ length: count }, (_, i) => ({ id: `${prefix}${i}`, nodes: [coil(`${prefix}coil${i}`, 'A', 300)] })) + const head = mk(1000, 'r') + const work = [{ id: 'rNEW', nodes: [coil('NODE_coilN', 'Z', 300)] }, ...head] + + const { flows } = diffLd(head, work) + + expect(flows).toHaveLength(1001) + expect(flows[0].original?.id).toBe('r0') + expect(flows[0].current?.id).toBe('rNEW') + }) + + it('falls back to positional pairing when rung ids are duplicated', () => { + const head: Rung[] = [ + { id: 'dup', nodes: [contact('NODE_c1', 'X', 100)] }, + { id: 'dup', nodes: [coil('NODE_coil2', 'B', 300)] }, + ] + const work: Rung[] = [{ id: 'dup', nodes: [contact('NODE_c1', 'Y', 100)] }, head[1]] + + const { flows, nodeDiffMaps } = diffLd(head, work) + + expect(flows).toHaveLength(2) + expect(nodeDiffMaps.current.get('NODE_c1')).toBe('modified') + expect(nodeDiffMaps.current.get('NODE_coil2')).toBe('unchanged') + }) +}) + +describe('computeGraphicalDiff — FBD', () => { + it('reports a rebound variable as modified rather than add + remove', () => { + const original = fbd({ nodes: [contact('NODE_a', 'IN1', 100), coil('NODE_b', 'OUT', 300)] }) + const current = fbd({ nodes: [contact('NODE_a', 'IN2', 100), coil('NODE_b', 'OUT', 300)] }) + + const { nodeDiffMaps, isLadder } = computeGraphicalDiff(original, current, 'pous/programs/main.fbd') + + expect(isLadder).toBe(false) + expect(nodeDiffMaps.original.get('NODE_a')).toBe('modified') + expect(nodeDiffMaps.current.get('NODE_a')).toBe('modified') + expect(nodeDiffMaps.current.get('NODE_b')).toBe('unchanged') + }) +}) diff --git a/src/backend/shared/utils/graphical-diff.ts b/src/backend/shared/utils/graphical-diff.ts new file mode 100644 index 000000000..c5c2232bd --- /dev/null +++ b/src/backend/shared/utils/graphical-diff.ts @@ -0,0 +1,537 @@ +/** + * Graphical diff utilities — pure data transformation for LD/FBD flow comparison. + * + * Parses IEC 61131-3 source files containing embedded JSON flow data, + * extracts variable declarations, and computes semantic diffs between two versions. + * + * This module is backend-only. Frontend accesses it through VersionControlPort. + */ + +import type { Edge, Node } from '@xyflow/react' + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +export type DiffStatus = 'added' | 'removed' | 'modified' | 'unchanged' + +export type FlowData = { + /** Rung id as serialized in the POU body. Present for LD, absent for FBD (single rung). */ + id?: string + nodes: Node[] + edges: Edge[] + /** Saved viewport dimensions `[width, height]` — matches the ladder editor default `[1530, 200]`. */ + reactFlowViewport?: [number, number] +} + +type RungPair = { original: FlowData | null; current: FlowData | null } + +export type ParsedVariable = { + name: string + type: string + class: string + location?: string + initialValue?: string +} + +export type VarDiffEntry = { + name: string + status: DiffStatus + original?: ParsedVariable + current?: ParsedVariable +} + +export type GraphicalDiffResult = { + flows: { + original: FlowData | null + current: FlowData | null + /** Per-side dimensions — each RungCell uses its own so the smaller side doesn't inherit empty space from the larger one. */ + originalHeight: number + currentHeight: number + originalWidth: number + currentWidth: number + }[] + changedIndexes: number[] + variableDiff: VarDiffEntry[] + nodeDiffMaps: { original: Map; current: Map } + edgeDiffMaps: { original: Map; current: Map }[] + isLadder: boolean +} + +// Node types that should never show diff highlighting (structural/auxiliary) +const STRUCTURAL_NODE_TYPES = new Set([ + 'powerRail', + 'parallel', + 'placeholder', + 'parallelPlaceholder', + 'mockNode', + 'variable', +]) + +// --------------------------------------------------------------------------- +// Flow data extraction +// --------------------------------------------------------------------------- + +function extractFlowData(content: string, ext: 'ld' | 'fbd'): FlowData[] | null { + const endMatch = content.match(/\b(END_PROGRAM|END_FUNCTION_BLOCK|END_FUNCTION)\b/i) + if (!endMatch || endMatch.index === undefined) return null + + const beforeEnd = content.slice(0, endMatch.index) + const endVarIdx = beforeEnd.lastIndexOf('END_VAR') + if (endVarIdx === -1) return null + + const bodyContent = beforeEnd.slice(endVarIdx + 'END_VAR'.length).trim() + try { + let parsed: unknown = JSON.parse(bodyContent) as unknown + if (typeof parsed === 'string') parsed = JSON.parse(parsed) as unknown + + type RawFlow = { + id?: string + nodes?: Node[] + edges?: Edge[] + reactFlowViewport?: [number, number] + } + + if (ext === 'ld') { + const rungs = (parsed as { rungs?: RawFlow[] }).rungs + if (!Array.isArray(rungs)) return null + return rungs.map((r) => ({ + id: r.id, + nodes: r.nodes ?? [], + edges: r.edges ?? [], + reactFlowViewport: r.reactFlowViewport, + })) + } else { + const rung = (parsed as { rung?: RawFlow }).rung + if (!rung) return null + return [{ nodes: rung.nodes ?? [], edges: rung.edges ?? [], reactFlowViewport: rung.reactFlowViewport }] + } + } catch { + return null + } +} + +// --------------------------------------------------------------------------- +// Variable parsing +// --------------------------------------------------------------------------- + +function extractVariables(content: string): ParsedVariable[] { + const variables: ParsedVariable[] = [] + const varBlockRegex = /(VAR(?:_INPUT|_OUTPUT|_IN_OUT|_EXTERNAL|_GLOBAL|_TEMP)?)\s*\n([\s\S]*?)END_VAR/g + let match: RegExpExecArray | null + while ((match = varBlockRegex.exec(content)) !== null) { + const varClass = match[1] + const blockBody = match[2] + const lineRegex = /^\s*(\w+)\s*:\s*(\S+)(?:\s+AT\s+(\S+))?(?:\s*:=\s*([^;]+))?\s*;/gm + let lineMatch: RegExpExecArray | null + while ((lineMatch = lineRegex.exec(blockBody)) !== null) { + variables.push({ + name: lineMatch[1], + type: lineMatch[2], + class: varClass, + location: lineMatch[3] || undefined, + initialValue: lineMatch[4]?.trim() || undefined, + }) + } + } + return variables +} + +// --------------------------------------------------------------------------- +// Variable diffing +// --------------------------------------------------------------------------- + +function computeVariableDiff(originalContent: string, currentContent: string): VarDiffEntry[] { + const origVars = extractVariables(originalContent) + const currVars = extractVariables(currentContent) + + const origByName = new Map(origVars.map((v) => [v.name, v])) + const currByName = new Map(currVars.map((v) => [v.name, v])) + + const entries: VarDiffEntry[] = [] + const seen = new Set() + + for (const [name, curr] of currByName) { + seen.add(name) + const orig = origByName.get(name) + if (!orig) { + entries.push({ name, status: 'added', current: curr }) + } else { + const changed = + orig.type !== curr.type || + orig.class !== curr.class || + orig.location !== curr.location || + orig.initialValue !== curr.initialValue + if (changed) { + entries.push({ name, status: 'modified', original: orig, current: curr }) + } + } + } + + for (const [name, orig] of origByName) { + if (!seen.has(name)) { + entries.push({ name, status: 'removed', original: orig }) + } + } + + return entries +} + +// --------------------------------------------------------------------------- +// Rung alignment +// --------------------------------------------------------------------------- + +function pairRungsByIndex(original: FlowData[], current: FlowData[]): RungPair[] { + const pairs: RungPair[] = [] + for (let i = 0; i < Math.max(original.length, current.length); i++) { + pairs.push({ original: original[i] ?? null, current: current[i] ?? null }) + } + return pairs +} + +function hasUniqueIds(flows: FlowData[]): boolean { + const ids = flows.map((f) => f.id).filter((id): id is string => !!id) + return ids.length === flows.length && new Set(ids).size === ids.length +} + +// The LCS table below is quadratic and allocated on the main thread, once per +// changed file. Past this, positional pairing is good enough. +const MAX_ALIGNABLE_RUNGS = 1000 + +/** + * Aligns rungs by their serialized id via LCS, so inserting or deleting a rung + * doesn't shift every rung below it against the wrong counterpart. Falls back to + * positional pairing when the ids can't be trusted. + */ +function alignRungs(originalFlows: FlowData[] | null, currentFlows: FlowData[] | null): RungPair[] { + const original = originalFlows ?? [] + const current = currentFlows ?? [] + if (original.length === 0 || current.length === 0) return pairRungsByIndex(original, current) + if (!hasUniqueIds(original) || !hasUniqueIds(current)) return pairRungsByIndex(original, current) + + const m = original.length + const n = current.length + if (m > MAX_ALIGNABLE_RUNGS || n > MAX_ALIGNABLE_RUNGS) return pairRungsByIndex(original, current) + + // lcs[i][j] = length of the longest common id subsequence of original[i..] and current[j..] + const lcs: number[][] = Array.from({ length: m + 1 }, () => new Array(n + 1).fill(0)) + for (let i = m - 1; i >= 0; i--) { + for (let j = n - 1; j >= 0; j--) { + lcs[i][j] = original[i].id === current[j].id ? lcs[i + 1][j + 1] + 1 : Math.max(lcs[i + 1][j], lcs[i][j + 1]) + } + } + + // Ids intersect nowhere: they were regenerated wholesale, so they aren't identity. + if (lcs[0][0] === 0) return pairRungsByIndex(original, current) + + const pairs: RungPair[] = [] + let i = 0 + let j = 0 + while (i < m && j < n) { + if (original[i].id === current[j].id) { + pairs.push({ original: original[i], current: current[j] }) + i++ + j++ + } else if (lcs[i + 1][j] >= lcs[i][j + 1]) { + pairs.push({ original: original[i], current: null }) + i++ + } else { + pairs.push({ original: null, current: current[j] }) + j++ + } + } + while (i < m) pairs.push({ original: original[i++], current: null }) + while (j < n) pairs.push({ original: null, current: current[j++] }) + return pairs +} + +// --------------------------------------------------------------------------- +// Semantic node matching +// --------------------------------------------------------------------------- + +function getSemanticKey(node: Node): string { + const type = node.type ?? '' + const varName = (node.data?.variable as { name?: string })?.name ?? '' + const variantName = (node.data?.variant as { name?: string })?.name ?? '' + return `${type}|${varName || variantName}` +} + +function getContentFingerprint(node: Node, includePosition = true): string { + const varName = (node.data?.variable as { name?: string })?.name ?? '' + const variantName = (node.data?.variant as { name?: string })?.name ?? '' + const variantType = (node.data?.variant as { type?: string })?.type ?? '' + const variantVars = (node.data?.variant as { variables?: Array<{ name: string; class: string }> })?.variables + const varsKey = variantVars + ? variantVars + .map((v) => `${v.name}:${v.class}`) + .sort() + .join(',') + : '' + const pos = includePosition ? `${node.position?.x ?? 0},${node.position?.y ?? 0}` : '' + return `${node.type}|${varName}|${variantName}|${variantType}|${varsKey}|${pos}` +} + +function getPositionKey(node: Node): string { + return `${node.type ?? ''}@${node.position?.x ?? 0},${node.position?.y ?? 0}` +} + +/** + * Rung-local matchers, strongest first. Node ids survive serialization and a + * rebind; the weaker keys recover matches when an XML round trip renumbered them. + */ +const RUNG_NODE_MATCHERS: Array<(node: Node) => string> = [(node) => node.id, getSemanticKey, getPositionKey] + +function matchNodePools( + originalPool: Node[], + currentPool: Node[], + keyOf: (node: Node) => string, + onMatch: (currentNode: Node, originalNode: Node) => void, +): { original: Node[]; current: Node[] } { + const candidates = new Map() + for (const node of originalPool) { + const key = keyOf(node) + const arr = candidates.get(key) ?? [] + arr.push(node) + candidates.set(key, arr) + } + + const matchedOrigIds = new Set() + const unmatchedCurrent: Node[] = [] + + for (const node of currentPool) { + const match = candidates.get(keyOf(node))?.find((c) => !matchedOrigIds.has(c.id)) + if (!match) { + unmatchedCurrent.push(node) + continue + } + matchedOrigIds.add(match.id) + onMatch(node, match) + } + + return { original: originalPool.filter((n) => !matchedOrigIds.has(n.id)), current: unmatchedCurrent } +} + +/** + * Matching is scoped to paired rungs: a global pass lets an element consume the + * counterpart of an element in another rung whenever they share a semantic key + * (two unbound contacts, say), painting untouched rungs as modified/removed — + * DOPE-496. The one cross-rung pass left is a safety net for when rung alignment + * itself failed, not move detection: elements can't change rungs. + */ +function computeNodeDiffMap(rungPairs: RungPair[]): { + original: Map + current: Map +} { + const original = new Map() + const current = new Map() + + const settle = (currentNode: Node, originalNode: Node, includePosition: boolean) => { + const changed = + getContentFingerprint(currentNode, includePosition) !== getContentFingerprint(originalNode, includePosition) + current.set(currentNode.id, changed ? 'modified' : 'unchanged') + original.set(originalNode.id, changed ? 'modified' : 'unchanged') + } + + const isStructural = (node: Node) => STRUCTURAL_NODE_TYPES.has(node.type ?? '') + const leftoverOriginal: Node[] = [] + const leftoverCurrent: Node[] = [] + + for (const pair of rungPairs) { + for (const node of pair.original?.nodes ?? []) if (isStructural(node)) original.set(node.id, 'unchanged') + for (const node of pair.current?.nodes ?? []) if (isStructural(node)) current.set(node.id, 'unchanged') + + let pendingOriginal = (pair.original?.nodes ?? []).filter((n) => !isStructural(n)) + let pendingCurrent = (pair.current?.nodes ?? []).filter((n) => !isStructural(n)) + + for (const keyOf of RUNG_NODE_MATCHERS) { + if (pendingOriginal.length === 0 || pendingCurrent.length === 0) break + const rest = matchNodePools(pendingOriginal, pendingCurrent, keyOf, (currentNode, originalNode) => + settle(currentNode, originalNode, true), + ) + pendingOriginal = rest.original + pendingCurrent = rest.current + } + + leftoverOriginal.push(...pendingOriginal) + leftoverCurrent.push(...pendingCurrent) + } + + // Position is excluded: reaching here means the rung pair was wrong, so these + // rung-local coordinates were measured against a rung the node never sat in. + const strandedByOriginalId = new Map(leftoverOriginal.map((node) => [node.id, node])) + for (const node of leftoverCurrent) { + const match = strandedByOriginalId.get(node.id) + if (!match) { + current.set(node.id, 'added') + continue + } + strandedByOriginalId.delete(node.id) + settle(node, match, false) + } + + for (const node of leftoverOriginal) { + if (!original.has(node.id)) original.set(node.id, 'removed') + } + + return { original, current } +} + +// --------------------------------------------------------------------------- +// Semantic edge matching +// --------------------------------------------------------------------------- + +function getEdgeSemanticKey(edge: Edge, nodeKeyMap: Map): string { + const srcKey = nodeKeyMap.get(edge.source) ?? edge.source + const tgtKey = nodeKeyMap.get(edge.target) ?? edge.target + return `${srcKey}::${edge.sourceHandle ?? ''}-->${tgtKey}::${edge.targetHandle ?? ''}` +} + +function computeEdgeDiffMaps( + originalFlow: FlowData | null, + currentFlow: FlowData | null, + originalNodeDiffMap: Map, + currentNodeDiffMap: Map, +): { original: Map; current: Map } { + const original = new Map() + const current = new Map() + + if (!originalFlow && !currentFlow) return { original, current } + + const origNodeKeyMap = new Map() + const currNodeKeyMap = new Map() + + if (originalFlow) { + for (const node of originalFlow.nodes) origNodeKeyMap.set(node.id, getSemanticKey(node)) + } + if (currentFlow) { + for (const node of currentFlow.nodes) currNodeKeyMap.set(node.id, getSemanticKey(node)) + } + + const origEdgeKeys = new Set() + if (originalFlow) { + for (const edge of originalFlow.edges) origEdgeKeys.add(getEdgeSemanticKey(edge, origNodeKeyMap)) + } + + const currEdgeKeys = new Set() + if (currentFlow) { + for (const edge of currentFlow.edges) { + const key = getEdgeSemanticKey(edge, currNodeKeyMap) + currEdgeKeys.add(key) + if (!origEdgeKeys.has(key)) { + current.set(edge.id, 'added') + } else { + const srcModified = currentNodeDiffMap.get(edge.source) === 'modified' + const tgtModified = currentNodeDiffMap.get(edge.target) === 'modified' + current.set(edge.id, srcModified || tgtModified ? 'modified' : 'unchanged') + } + } + } + + if (originalFlow) { + for (const edge of originalFlow.edges) { + const key = getEdgeSemanticKey(edge, origNodeKeyMap) + if (!currEdgeKeys.has(key)) { + original.set(edge.id, 'removed') + } else { + const srcModified = originalNodeDiffMap.get(edge.source) === 'modified' + const tgtModified = originalNodeDiffMap.get(edge.target) === 'modified' + original.set(edge.id, srcModified || tgtModified ? 'modified' : 'unchanged') + } + } + } + + return { original, current } +} + +// --------------------------------------------------------------------------- +// Rung height computation +// --------------------------------------------------------------------------- + +function calcRungHeight(nodes: Node[]): number { + if (nodes.length === 0) return 80 + let minY = Infinity + let maxY = -Infinity + for (const node of nodes) { + const ny = node.position?.y ?? 0 + const nh = (node.measured?.height as number) ?? (node.height as number) ?? 40 + if (ny < minY) minY = ny + if (ny + nh > maxY) maxY = ny + nh + } + return Math.max(maxY - minY + 80, 120) +} + +function calcRungWidth(nodes: Node[]): number { + if (nodes.length === 0) return 400 + let maxX = 0 + for (const node of nodes) { + const nx = node.position?.x ?? 0 + const nw = (node.measured?.width as number) ?? (node.width as number) ?? 100 + if (nx + nw > maxX) maxX = nx + nw + } + return maxX + 40 +} + +// --------------------------------------------------------------------------- +// Public API: compute full graphical diff +// --------------------------------------------------------------------------- + +export function computeGraphicalDiff( + originalContent: string, + currentContent: string, + filePath: string, +): GraphicalDiffResult { + const ext = filePath.split('.').pop()?.toLowerCase() as 'ld' | 'fbd' + const isLadder = ext === 'ld' + + const originalFlows = extractFlowData(originalContent, ext) + const currentFlows = extractFlowData(currentContent, ext) + + const variableDiff = computeVariableDiff(originalContent, currentContent) + const rungPairs = alignRungs(originalFlows, currentFlows) + const nodeDiffMaps = computeNodeDiffMap(rungPairs) + + const flows: GraphicalDiffResult['flows'] = [] + const changedIndexes: number[] = [] + const edgeDiffMaps: GraphicalDiffResult['edgeDiffMaps'] = [] + + for (let i = 0; i < rungPairs.length; i++) { + const orig = rungPairs[i].original + const curr = rungPairs[i].current + + // Per-side dimensions (content bounds). Each side uses its own so the + // smaller side doesn't get padded to match the larger one. + const originalHeight = orig ? calcRungHeight(orig.nodes) : 80 + const currentHeight = curr ? calcRungHeight(curr.nodes) : 80 + const originalWidth = orig ? calcRungWidth(orig.nodes) : isLadder ? 0 : 400 + const currentWidth = curr ? calcRungWidth(curr.nodes) : isLadder ? 0 : 400 + + flows.push({ original: orig, current: curr, originalHeight, currentHeight, originalWidth, currentWidth }) + + const rungEdgeDiff = computeEdgeDiffMaps(orig, curr, nodeDiffMaps.original, nodeDiffMaps.current) + edgeDiffMaps.push(rungEdgeDiff) + + // Detect change from the semantic diff maps rather than raw JSON + // byte-comparison. A byte-level compare would flag rungs as changed on + // any non-semantic drift (stripped/reordered transient fields from + // the sync-back cycle, ReactFlow-injected runtime state, etc.) even + // when the ladder is visually identical. + const nodeChanged = + (curr?.nodes ?? []).some((n) => (nodeDiffMaps.current.get(n.id) ?? 'unchanged') !== 'unchanged') || + (orig?.nodes ?? []).some((n) => (nodeDiffMaps.original.get(n.id) ?? 'unchanged') !== 'unchanged') + const edgeChanged = + (curr?.edges ?? []).some((e) => (rungEdgeDiff.current.get(e.id) ?? 'unchanged') !== 'unchanged') || + (orig?.edges ?? []).some((e) => (rungEdgeDiff.original.get(e.id) ?? 'unchanged') !== 'unchanged') + if (!orig || !curr || nodeChanged || edgeChanged) { + changedIndexes.push(i) + } + } + + return { + flows, + changedIndexes, + variableDiff, + nodeDiffMaps, + edgeDiffMaps, + isLadder, + } +} diff --git a/src/frontend/components/_features/[start]/account/index.tsx b/src/frontend/components/_features/[start]/account/index.tsx new file mode 100644 index 000000000..27b9f6ee4 --- /dev/null +++ b/src/frontend/components/_features/[start]/account/index.tsx @@ -0,0 +1,138 @@ +/** + * The Edge account, on the start screen. + * + * The workspace has the activity bar's account slot, but the activity bar only exists + * once a project is open. This is the same account, the same dropdown and the same + * sign-in dialog, reachable from the screen the user actually lands on. + * + * IT NEVER OPENS BY ITSELF, on either build. `workspace-activity-bar` opens the dialog + * unprompted where `requiresEdgeAccount` is set, and that is right there: a project was + * asked for and could not be reached without a session. Here nothing has been asked + * for. The start screen lists local projects on the desktop and explains itself on the + * web, and both are usable with no account at all — so the way in is offered, not + * imposed. + */ + +import { LogIn } from 'lucide-react' +import { useState } from 'react' + +import { useCapabilities, useEdgeAccountPort } from '../../../../../middleware/shared/providers' +import { useEdgeAccount } from '../../../../hooks/use-edge-account' +import { cn } from '../../../../utils/cn' +import { EdgeAccountMenu } from '../../../_organisms/edge-account-menu' +import { EdgeSignInModal } from '../../../_organisms/edge-sign-in-modal' +import { MenuItem } from '../menu' + +/** + * The row geometry every item in this menu shares, from the `Button` atom `MenuItem` + * wraps: `h-12 gap-3 px-5 py-3` at `text-xl`. Kept as a constant so a signed-in row and + * a signed-out one cannot drift apart. + * + * `w-full min-w-48` rather than the `w-48` the other rows use: what makes this line up + * is the left edge and the icon column, not the width, and a fixed 192px left barely + * 120px for the name — enough to clip most real ones. The menu column caps it at 240px + * either way. + */ +const ROW_CLASSES = + 'flex h-12 w-full min-w-48 items-center gap-3 px-5 py-3 font-caption text-xl font-medium text-neutral-1000 dark:text-white' + +/** + * The size of whatever leads a row here — the sign-in icon, or the avatar once someone + * is signed in. + * + * `size-5` matches the 20px interface icons this menu uses, and is deliberately NOT the + * avatar's own `size-7` default. That default is right in the activity bar, where the + * avatar is the whole control; here it has to line up with a folder and a video icon. + */ +const LEADING_GLYPH_CLASSES = 'size-5' + +const StartAccountSection = () => { + const caps = useCapabilities() + const edgeAccount = useEdgeAccountPort() + const { + status, + user, + planCaption, + signedOutReason, + refresh, + signOut: signOutOfAccount, + } = useEdgeAccount(caps.hasEdgeAccount, edgeAccount) + const [dialogOpen, setDialogOpen] = useState(false) + + // `hasEdgeAccount`, NOT `hasAuthentication`: the autonomy-node build is also + // authenticated but talks to its own API, where Edge's account endpoints do not + // exist — this would offer a sign-in that cannot work. + if (!caps.hasEdgeAccount || !edgeAccount) { + return null + } + + // Nothing while the first read is in flight. A "Sign in" row that appears and then + // vanishes is worse than a beat of nothing, and a returning user's stored session is + // usually about to resolve. + if (status === 'loading') { + return null + } + + if (status === 'signed-in' && user) { + return ( + // The WHOLE ROW is the trigger, avatar and name together. The name is what a + // person aims at here, and having it outside the trigger meant clicking the + // obvious place did nothing. + // + // `triggerClassName` carries the geometry of a `MenuItem` so the row sits on the + // same grid as Open, Tutorials and Exit. It cannot BE a `MenuItem`, because the + // trigger is already a button and nesting buttons is invalid HTML. + + {user.name} + + } + onSignOut={() => { + void signOutOfAccount() + }} + /> + ) + } + + return ( + <> + setDialogOpen(true)} + aria-label='Sign in to Autonomy Edge' + // Same width rule as the signed-in row, so the slot does not change shape + // when someone signs in or out. + className='w-full min-w-48' + > + {/* An icon, not an avatar with nobody in it: the avatar falls back to `?` + when it has no name, and a question mark beside "Sign in" reads as + something being wrong rather than as an invitation. */} + + {/* Someone whose session died under them is not being welcomed; they are being + told what happened. */} + {signedOutReason === 'expired' ? 'Session ended' : 'Sign in'} + + + { + setDialogOpen(false) + // The hook owns the profile and has no way to know a sign-in happened inside + // a dialog it did not open. + void refresh() + }} + /> + + ) +} + +export { StartAccountSection } diff --git a/src/frontend/components/_features/[start]/cloud-projects/index.tsx b/src/frontend/components/_features/[start]/cloud-projects/index.tsx new file mode 100644 index 000000000..acf99e9f5 --- /dev/null +++ b/src/frontend/components/_features/[start]/cloud-projects/index.tsx @@ -0,0 +1,282 @@ +/** + * The signed-in user's Autonomy Edge projects, on the start screen. + * + * Sits above the local Projects section so the two are visible together: this is the + * only place in either product where a person sees what is on their machine and what is + * on their account side by side, and reaching one from the other is the point. + * + * Opening one goes through `openProjectByPath` exactly as a local project does. The + * editor's adapter decides which world the identifier belongs to, so nothing here — and + * nothing in the save flow afterwards — has to know the project came from the cloud. + * + * THE SPACE IS RESERVED, always. Once a build has an Edge account the heading stays put + * whether or not anyone is signed in, so the start screen does not reflow underneath the + * user the moment a session resolves — and so someone who has never signed in still + * learns that the space is theirs to fill. + * + * WHY IT DOES NOT ASK WHO IS SIGNED IN. Mounting a second account hook beside the menu's + * would mean a second `/auth/me` on every start. The list request already reports which + * kind of nothing it found, which is enough. What it does subscribe to is the session's + * own restored/expired signal — which is what makes the projects appear the moment + * someone signs in through the menu, and go away when they sign out, with no polling and + * no extra request. + */ + +import { CloudUpload } from 'lucide-react' +import { useCallback, useEffect, useState } from 'react' + +import type { CloudProjectsResult, CloudProjectSummary } from '../../../../../middleware/shared/ports/project-port' +import { useCapabilities, useEdgeAccountPort, useProject } from '../../../../../middleware/shared/providers' +import { useOpenPLCStore } from '../../../../store' +import { File } from '../../../_atoms/file' +import { EdgeSignInModal } from '../../../_organisms/edge-sign-in-modal' +import { toast } from '../../[app]/toast/use-toast' + +/** + * Five, as the product asks. It is a shortcut to recent work, not a project browser — + * Edge's own SPA is where someone goes to see everything. + */ +const RECENT_LIMIT = 5 + +export type StartCloudProjectsProps = { + /** Same filter box the local list uses, so one search covers both sections. */ + searchNameFilterValue: string + /** + * Bumped by whoever changed what is on the account, to ask for a re-read. + * + * A number rather than a callback handed upward: this list already reloads from an + * effect, and a counter turns "something changed" into an ordinary dependency instead + * of an imperative handle the parent has to hold and remember to call. Publishing a + * local project is the case that needs it — the new project belongs at the top of this + * list, and until this existed it only appeared after a restart. + */ + revision?: number +} + +const StartCloudProjects = ({ searchNameFilterValue, revision = 0 }: StartCloudProjectsProps) => { + const caps = useCapabilities() + const edgeAccount = useEdgeAccountPort() + const project = useProject() + const { + sharedWorkspaceActions: { handleOpenProjectResponse }, + } = useOpenPLCStore() + + /** `null` until the first answer lands — which is not the same as having none. */ + const [result, setResult] = useState(null) + const [signInOpen, setSignInOpen] = useState(false) + + const available = caps.hasEdgeAccount && project.listRecentCloudProjects !== undefined + + const load = useCallback(async () => { + if (!project.listRecentCloudProjects) { + return + } + + // `catch` because this is the ONLY thing standing between a failed IPC call and the + // start screen: a rejection inside this effect takes the whole renderer down, which + // is exactly what happened when the list was called against a main process that did + // not have the channel yet. A cloud list nobody asked for must never cost someone + // their local projects. + setResult( + await project.listRecentCloudProjects(RECENT_LIMIT).catch((): CloudProjectsResult => ({ status: 'unreachable' })), + ) + }, [project]) + + useEffect(() => { + if (!available) { + return + } + + void load() + // `revision` is a dependency, not a value this reads: changing it is the whole + // signal. Listed explicitly so the exhaustive-deps rule and the reader agree about + // why a number nothing dereferences belongs here. + }, [available, load, revision]) + + // The session's own signal, not a poll: signing in through the menu makes the list + // appear, and signing out empties it, without either component knowing about the other. + useEffect(() => { + if (!available || !edgeAccount) { + return + } + + const unsubscribeRestored = edgeAccount.session.onRestored(() => void load()) + const unsubscribeExpired = edgeAccount.session.onExpired(() => setResult({ status: 'signed-out' })) + + return () => { + unsubscribeRestored() + unsubscribeExpired() + } + }, [available, edgeAccount, load]) + + const openProject = async (summary: CloudProjectSummary) => { + const result = await project.openProjectByPath(summary.id) + + if (result.success && result.data) { + handleOpenProjectResponse(result.data) + + return + } + + toast({ + title: 'Cannot open the project.', + // The adapter's own message, which distinguishes "not signed in" from "Edge + // answered 403" from "could not reach Edge" — all three are actionable and all + // three are different. + description: result.error?.description ?? `${summary.name} could not be opened.`, + variant: 'fail', + }) + } + + const filter = searchNameFilterValue.trim().toLowerCase() + const projects = result?.status === 'ok' ? result.projects : [] + const visible = filter ? projects.filter((summary) => summary.name.toLowerCase().includes(filter)) : projects + + // Nothing at all only where there is no Edge account to speak of — the autonomy-node + // build, or a platform with no such channel. Everywhere else the space is reserved. + // + // `edgeAccount` is in the guard rather than checked at each use: without the port there + // is no sign-in to offer and no session to observe, so there is nothing to render — and + // stating it once here is what lets the invitation below use it without a null check. + if (!available || !edgeAccount || result?.status === 'unavailable') { + return null + } + + return ( + // `mb-10` is the only spacing this section adds. The heading-to-cards rhythm is + // `mb-6` (24px), so the gap BETWEEN the two sections has to be larger than that or + // the local "Projects" heading reads as a label for the cloud cards above it. +
+

+ Autonomy Edge Cloud Projects +

+ + {/* One line for each kind of nothing, because they are not the same thing to say. + Telling someone to sign in when they already are and are merely offline sends + them to fix the wrong problem — which is why the list request reports which + case it hit rather than answering with an empty array. */} + {result === null ? ( + // Placeholder cards, not a spinner and not blank. + // + // A returning user's stored session is usually about to resolve, so flashing + // "Sign in" at them first would be worse than showing nothing — but showing + // nothing made the section look empty rather than busy, and the row below then + // jumped as the real cards arrived. Cards the same size as the real ones keep + // the layout still and say what is coming. + // + // Three is not a guess at how many will arrive: it is enough to read as a row + // without claiming a count. `aria-hidden` because there is nothing here to + // announce, and `role=status` on the wrapper is what a screen reader hears. +
+ {[0, 1, 2].map((index) => ( +
+ {/* Mirrors the folder card's own layout — the tab across the top, then the + two lines of text at the bottom left — so the placeholder resolves into + the real card instead of being replaced by something differently shaped. */} +
+
+
+
+
+
+
+ ))} +
+ ) : result.status === 'signed-out' ? ( + /* A card with a real button, not a line of grey text. This is the one place in + the editor where someone who has never signed in learns what an account is + FOR, and a sentence they can ignore converts nobody. Tinted with the brand + rather than a warning colour, because it is an invitation, not a problem. + + `blue-500` for the tint and not `brand`: the brand token is a `var()` holding a + hex, and Tailwind 3 cannot reliably apply an opacity modifier to that. Same + substitution the account menu makes, same colour. */ +
+ + + +
+

+ Bring your cloud projects here +

+ {/* The CARD spans the row; the SENTENCE does not. It stretches to the same + right edge as the folders below so the space reads as one block, but a + line of body text a thousand pixels wide is genuinely hard to read, so + the paragraph keeps a measure and centres inside it. */} +

+ Sign in with your Autonomy Edge account to access Edge features. Open your cloud projects in this editor + and save straight back to them. +

+
+ +
+ + {/* For the visitor with no account at all — the other half of "everyone + connects". It opens Edge, because signing up is an email round-trip that + does not belong inside the editor. */} + + Create an account + +
+
+ ) : result.status === 'unreachable' ? ( +

+ Could not reach Autonomy Edge. Your local projects below are unaffected. +

+ ) : visible.length === 0 ? ( +

+ {filter + ? 'No cloud project matches that search.' + : 'No cloud projects yet. Create one on Autonomy Edge and it will show up here.'} +

+ ) : ( +
+ {visible.map((summary) => ( + void openProject(summary)} + className='overflow-hidden' + projectName={summary.name} + // The card's second line. A cloud project has no path on this machine, so + // it says where it does live rather than inventing a local one. + projectPath='Autonomy Edge' + lastModified={new Date(summary.updatedAt).toLocaleString()} + /> + ))} +
+ )} + + {/* Its own instance, and that is fine: this and the account row in the menu are + both signed-out-only, so the user reaches one or the other, never both. */} + { + setSignInOpen(false) + // The session's `onRestored` fires and reloads too; calling it here as well + // means the list is already arriving by the time the dialog is gone. + void load() + }} + /> +
+ ) +} + +export { StartCloudProjects } diff --git a/src/frontend/components/_features/[start]/upload-to-cloud/index.tsx b/src/frontend/components/_features/[start]/upload-to-cloud/index.tsx new file mode 100644 index 000000000..947a43022 --- /dev/null +++ b/src/frontend/components/_features/[start]/upload-to-cloud/index.tsx @@ -0,0 +1,308 @@ +/** + * Publishing a project from this machine to Autonomy Edge. + * + * The desktop's answer to Edge's own "Import project" dialog, and it asks for the same + * three things: where it goes, what it is called, and whether anyone else can see it. The + * difference is what the user has to do — on the web they are told to zip the folder + * themselves ("right-click → Compress"), while here the project is already on disk with a + * path the editor holds, so it makes the archive. Nothing about that belongs on screen. + * + * WHY EACH FAILURE GETS ITS OWN SENTENCE. Publishing can fail for reasons with completely + * different remedies: a folder that was never an OpenPLC project, a project too large for + * the importer, a name already taken, a dropped connection. The last is the one worth + * being careful about — the import is not idempotent, so an unanswered request may have + * created the project anyway, and telling someone it failed would invite a duplicate. + */ + +import { CloudUpload, Loader2 } from 'lucide-react' +import { useCallback, useEffect, useState } from 'react' + +import type { CloudFoldersResult, UploadProjectFailure } from '../../../../../middleware/shared/ports/project-port' +import { useProject } from '../../../../../middleware/shared/providers' +import { cn } from '../../../../utils/cn' +import { Modal, ModalContent, ModalTitle } from '../../../_molecules/modal' + +export type UploadToCloudModalProps = { + open: boolean + onOpenChange: (open: boolean) => void + /** Absolute path of the project on this machine. */ + projectPath: string + /** Its local name, offered as the default. */ + projectName: string + /** Published successfully — the caller decides what to refresh. */ + onUploaded: (projectId: string | null) => void +} + +/** What to say for each way this can fail. One sentence, and something to do about it. */ +function describeFailure(failure: UploadProjectFailure): string { + switch (failure.reason) { + case 'no-manifest': + return 'This folder has no project.json, so it is not an OpenPLC project the importer can read.' + case 'empty': + return 'This folder has no project files in it.' + case 'too-many-files': + return `This project has ${failure.count} files, which is more than the importer accepts.` + case 'too-deep': + return 'This project nests folders deeper than the importer accepts.' + case 'file-too-large': + return `${failure.relativePath} is ${Math.round(failure.bytes / (1024 * 1024))}MB, which is over the 50MB limit for a single file.` + case 'too-large': + return `This project is ${Math.round(failure.bytes / (1024 * 1024))}MB, which is over the 100MB limit.` + case 'unreadable': + return failure.message + case 'signed-out': + return 'Your Autonomy Edge session ended. Sign in again and retry.' + case 'unreachable': + // Deliberately not "the upload failed": it may well have succeeded. + return 'Autonomy Edge could not be reached, so it is unclear whether the project was created. Check Autonomy Edge before trying again.' + case 'rejected': + return failure.message + default: { + const exhaustive: never = failure + + return `Publishing failed: ${JSON.stringify(exhaustive)}` + } + } +} + +/** + * The branch drawn to the left of a folder's name. + * + * Box-drawing characters rather than plain indentation: at one or two levels an indent + * alone reads as a list that happens to be ragged, while a connector says the thing that + * matters — this folder is INSIDE that one, and picking it puts the project there. The + * same shape Edge's own import dialog uses, so the two products describe one hierarchy + * the same way. + */ +function folderConnector(depth: number): string { + return depth === 0 ? '' : `${' '.repeat(depth - 1)}└── ` +} + +const UploadToCloudModal = ({ open, onOpenChange, projectPath, projectName, onUploaded }: UploadToCloudModalProps) => { + const project = useProject() + + const [folders, setFolders] = useState(null) + const [parentFolderId, setParentFolderId] = useState('') + const [name, setName] = useState(projectName) + const [visibility, setVisibility] = useState<'public' | 'private'>('private') + const [busy, setBusy] = useState(false) + const [error, setError] = useState(null) + + const loadFolders = useCallback(async () => { + if (!project.listCloudFolders) { + setFolders({ status: 'unreachable' }) + + return + } + + const result = await project.listCloudFolders() + + setFolders(result) + + if (result.status === 'ok' && result.folders.length > 0) { + // The account root is first, and it is the destination that always exists. + setParentFolderId(result.folders[0].id) + } + }, [project]) + + // Loaded on open rather than on mount: the modal lives beside every project card, and + // asking Edge for folders because a menu exists would be a request per card. + useEffect(() => { + if (!open) { + return + } + + setError(null) + setName(projectName) + setVisibility('private') + setFolders(null) + void loadFolders() + }, [open, projectName, loadFolders]) + + const publish = async () => { + if (!project.uploadProjectToCloud || !parentFolderId) { + return + } + + setBusy(true) + setError(null) + + const trimmed = name.trim() + const result = await project.uploadProjectToCloud({ + projectPath, + parentFolderId, + // Omitted when unchanged, so the importer keeps using the name in project.json + // rather than being handed the same value twice. + projectName: trimmed && trimmed !== projectName ? trimmed : undefined, + visibility, + }) + + setBusy(false) + + if (result.status === 'ok') { + onUploaded(result.projectId) + onOpenChange(false) + + return + } + + setError(describeFailure(result.failure)) + } + + const ready = folders?.status === 'ok' && parentFolderId.length > 0 + + return ( + + + + + + + Upload to Autonomy Edge + +

+ This project stays on your computer. A copy is created on Autonomy Edge. +

+ + {folders === null ? ( +
+ + Loading your folders... +
+ ) : folders.status === 'signed-out' ? ( +

+ Sign in to your Autonomy Edge account to publish this project. +

+ ) : folders.status === 'unreachable' ? ( +
+

+ Autonomy Edge could not be reached. Your project on this computer is unaffected. +

+ +
+ ) : ( +
+
+ + Destination folder + + {/* A tree, not a dropdown. The whole hierarchy is worth seeing at once — + choosing where a project lands is the decision this dialog exists for, + and a collapsed control hides the very structure being chosen from. + Scrolls past a handful so a deep account cannot push the buttons off. + + Native radios underneath, visually hidden: they carry the arrow-key + navigation, the focus ring and the screen-reader semantics that a + hand-rolled listbox would have to reimplement, usually worse. */} +
+ {folders.folders.map((folder) => { + const selected = folder.id === parentFolderId + + return ( + + ) + })} +
+
+ + + +
+ Visibility + {/* Private first, and selected: publishing someone's control program to the + world is not a default anyone should get by pressing Enter. */} + {(['private', 'public'] as const).map((option) => ( + + ))} +
+
+ )} + + {error && ( +

{error}

+ )} + +
+ + +
+
+
+ ) +} + +export { UploadToCloudModal } diff --git a/src/frontend/components/_features/[workspace]/branches/branch-merge-view.tsx b/src/frontend/components/_features/[workspace]/branches/branch-merge-view.tsx new file mode 100644 index 000000000..2dafdd7d3 --- /dev/null +++ b/src/frontend/components/_features/[workspace]/branches/branch-merge-view.tsx @@ -0,0 +1,975 @@ +/** + * Merging one branch into another, file by file. + * + * Reached from the branch switcher. It is a whole screen because the decision needs one: + * a three-way view of source, target and their common ancestor, a diff per file, and a + * conflict resolver for the files the server says will collide. + * + * PLATFORM-FREE ON PURPOSE. It talks to the version-control port and takes + * `onBack`/`onMerged` instead of navigating. The web wraps it in a router page at + * `/merge`; the desktop, which has no router, lays it over the workspace. Both get the + * same screen from the same code, which is the only way the two stay identical, and the + * reason the desktop could not have this feature before: the page was wired straight to + * the web own API layer, which the editor does not have. + */ + +import { DiffEditor } from '@monaco-editor/react' +import { ResizableHandle, ResizablePanel, ResizablePanelGroup } from '@root/frontend/components/_organisms/panel' +import { cn } from '@root/frontend/utils/cn' +import type { Branch, BranchDiffWithBase } from '@root/middleware/shared/ports/version-control-port' +import { MergeConflictError } from '@root/middleware/shared/ports/version-control-port' +import { useTheme, useVersionControl } from '@root/middleware/shared/providers' +import { + AlertCircle, + ArrowLeft, + ChevronDown, + ChevronLeft, + ChevronRight, + ChevronUp, + File, + Folder, + FolderOpen, + GitMerge, + Search, +} from 'lucide-react' +import { useEffect, useMemo, useState } from 'react' + +import { GraphicalDiffViewer, isGraphicalFile } from '../editor/diff-viewer' +import { useDiffEditorTeardown, useDiffModelPaths } from '../editor/diff-viewer/use-diff-editor-teardown' +import { TextConflictResolver } from './merge-text-conflict-resolver' + +// --------------------------------------------------------------------------- +// File tree types & helpers (same shape as history-page so the look matches) +// --------------------------------------------------------------------------- + +type FileStatus = 'A' | 'M' | 'D' | 'U' | 'C' | 'R' + +const FILE_STATUS_CONFIG: Record = { + A: { label: 'Added', color: 'text-green-500' }, + M: { label: 'Modified', color: 'text-yellow-500' }, + D: { label: 'Deleted', color: 'text-red-500' }, + U: { label: 'Unchanged', color: 'text-neutral-400' }, + C: { label: 'Conflict', color: 'text-amber-500' }, + R: { label: 'Resolved', color: 'text-green-500' }, +} + +type FileTreeNode = { + name: string + path: string + type: 'file' | 'folder' + status?: FileStatus + children?: FileTreeNode[] +} + +function buildTree(files: { path: string; status?: FileStatus }[]): FileTreeNode[] { + const root: FileTreeNode[] = [] + for (const file of files) { + const parts = file.path.split('/').filter(Boolean) + if (parts.length === 0) continue + let current = root + for (let i = 0; i < parts.length; i++) { + const name = parts[i] + const isFile = i === parts.length - 1 + const fullPath = parts.slice(0, i + 1).join('/') + let existing = current.find((n) => n.name === name) + if (!existing) { + existing = { + name, + path: fullPath, + type: isFile ? 'file' : 'folder', + status: isFile ? file.status : undefined, + children: isFile ? undefined : [], + } + current.push(existing) + } + if (!isFile) { + if (!existing.children) { + existing.children = [] + existing.type = 'folder' + } + current = existing.children + } + } + } + const sortNodes = (nodes: FileTreeNode[]) => { + nodes.sort((a, b) => { + if (a.type !== b.type) return a.type === 'folder' ? -1 : 1 + return a.name.localeCompare(b.name) + }) + for (const node of nodes) { + if (node.children) sortNodes(node.children) + } + } + sortNodes(root) + return root +} + +function getLanguageFromPath(path: string): string { + const ext = path.split('.').pop()?.toLowerCase() + switch (ext) { + case 'json': + return 'json' + case 'st': + case 'il': + case 'sfc': + return 'st' + case 'py': + return 'python' + case 'c': + return 'c' + case 'cpp': + return 'cpp' + default: + return 'plaintext' + } +} + +function formatContentForDisplay(path: string, content: string): string { + const ext = path.split('.').pop()?.toLowerCase() + if (ext !== 'ld' && ext !== 'fbd') return content + const endMatch = content.match(/\b(END_PROGRAM|END_FUNCTION_BLOCK|END_FUNCTION)\b/i) + if (!endMatch || endMatch.index === undefined) return content + const endKeyword = endMatch[0] + const beforeEnd = content.slice(0, endMatch.index) + const endVarIdx = beforeEnd.lastIndexOf('END_VAR') + if (endVarIdx === -1) return content + const declaration = beforeEnd.slice(0, endVarIdx + 'END_VAR'.length) + return `${declaration}\n\n(* ${ext.toUpperCase()} graphical data omitted *)\n\n${endKeyword}` +} + +// --------------------------------------------------------------------------- +// File tree item +// --------------------------------------------------------------------------- + +function FileStatusBadge({ status }: { status: FileStatus }) { + const config = FILE_STATUS_CONFIG[status] + return ( + + {status} + + ) +} + +function FileTreeItem({ + node, + depth, + selectedPath, + onSelect, + expandedFolders, + onToggleFolder, +}: { + node: FileTreeNode + depth: number + selectedPath: string | null + onSelect: (path: string) => void + expandedFolders: Set + onToggleFolder: (path: string) => void +}) { + const isExpanded = expandedFolders.has(node.path) + const isSelected = node.path === selectedPath + + if (node.type === 'folder') { + return ( +
+ + {isExpanded && + node.children?.map((child) => ( + + ))} +
+ ) + } + + return ( + + ) +} + +// --------------------------------------------------------------------------- +// Merge page +// --------------------------------------------------------------------------- + +export type BranchMergeViewProps = { + projectId: string + /** The branch being merged in. */ + sourceBranch: string + /** Where it lands. Omitted when opened from the branch you are on — the default branch then wins. */ + targetParam?: string + /** Leave without merging. */ + onBack: () => void + /** + * The merge landed. The project on the server has moved, so the host has to reload it — + * this view cannot, and leaving the user on a stale copy would be worse than closing. + */ + onMerged: () => void +} + +export function BranchMergeView({ projectId, sourceBranch, targetParam, onBack, onMerged }: BranchMergeViewProps) { + const versionControl = useVersionControl() + // Monaco is mounted directly here, so the library's reversed teardown applies: without + // this, closing the screen raises "TextModel got disposed before DiffEditorWidget model + // got reset" as an uncaught error. Measured in the running app before the fix. + const diffEditorRef = useDiffEditorTeardown() + const diffModelPaths = useDiffModelPaths() + + const [selectedFile, setSelectedFile] = useState(null) + const [expandedFolders, setExpandedFolders] = useState>(new Set()) + const [searchQuery, setSearchQuery] = useState('') + const [mergeError, setMergeError] = useState(null) + const [showMergeOptionsModal, setShowMergeOptionsModal] = useState(false) + const [postMergeError, setPostMergeError] = useState(null) + + // Per-file resolution content (path → user-edited resolved content). + // A path present here AND in `resolvedFiles` means the user marked it as resolved. + const [resolutions, setResolutions] = useState>({}) + const [resolvedFiles, setResolvedFiles] = useState>(new Set()) + + // Editable commit message — pre-filled with the default template when the + // diff loads. User can override it before clicking "Merge". + const [commitMessage, setCommitMessage] = useState('') + const [showCommitMessageEdit, setShowCommitMessageEdit] = useState(false) + + const [branches, setBranches] = useState([]) + + // The list is needed for two things only: resolving the default target when none was + // given, and finding the source branch's id so it can be offered for deletion after a + // merge. A failure leaves it empty, which degrades both gracefully. + useEffect(() => { + if (!versionControl) return + let alive = true + versionControl + .listBranches(projectId) + .then(({ branches: list }) => { + if (alive) setBranches(list) + }) + .catch(() => undefined) + return () => { + alive = false + } + }, [projectId, versionControl]) + + // `target` may be omitted when the user opens merge from the same branch + // they're on. Fall back to the repo's default branch (skipping source so + // the page never tries to merge a branch into itself). + const targetBranch = useMemo(() => { + if (targetParam && targetParam !== sourceBranch) return targetParam + const defaultBranch = branches.find((b) => b.isDefault && b.name !== sourceBranch) + return defaultBranch?.name ?? targetParam ?? '' + }, [targetParam, sourceBranch, branches]) + + const [data, setData] = useState(null) + const [isLoading, setIsLoading] = useState(true) + const [error, setError] = useState(null) + const [isMerging, setIsMerging] = useState(false) + // Its own flag, so the button can say which of the two steps is running. The web build + // read this from a second mutation; here the distinction is kept by hand. + const [isDeletingSource, setIsDeletingSource] = useState(false) + + // Re-runs when either branch changes, and drops a late answer for a pair the user has + // already moved off — otherwise switching target twice quickly can leave the first + // response on screen under the second one's heading. + useEffect(() => { + if (!versionControl?.getBranchDiffWithBase || !sourceBranch || !targetBranch || sourceBranch === targetBranch) { + setIsLoading(false) + return + } + + let alive = true + setIsLoading(true) + setError(null) + + versionControl + .getBranchDiffWithBase(projectId, sourceBranch, targetBranch) + .then((result) => { + if (!alive) return + setData(result) + setIsLoading(false) + }) + .catch((err: unknown) => { + if (!alive) return + setError(err instanceof Error ? err : new Error('Failed to load the branch diff')) + setIsLoading(false) + }) + + return () => { + alive = false + } + }, [projectId, sourceBranch, targetBranch, versionControl]) + + const conflictedPaths = useMemo(() => new Set(data?.conflicts ?? []), [data?.conflicts]) + + // Initialize commit message with default template once branch names are known + useEffect(() => { + if (commitMessage === '' && sourceBranch && targetBranch) { + setCommitMessage(`Merge branch '${sourceBranch}' into ${targetBranch}`) + } + }, [sourceBranch, targetBranch, commitMessage]) + + // Default branch can't be deleted; the prompt is hidden for it. + // Double-guard: rely on isDefault flag AND on well-known default names + // (in case the branch metadata hasn't been loaded yet or isDefault is stale). + const sourceBranchEntry = branches.find((b) => b.name === sourceBranch) + const sourceBranchIsDefault = sourceBranchEntry?.isDefault ?? false + const sourceLooksLikeDefault = sourceBranch === 'main' || sourceBranch === 'master' + const canDeleteSource = !!sourceBranchEntry && !sourceBranchIsDefault && !sourceLooksLikeDefault + + // Theme handling — this page can load standalone without DisplayMenu + const themePort = useTheme() + const isDark = themePort.getCurrentTheme() === 'dark' + useEffect(() => { + if (isDark) document.documentElement.classList.add('dark') + else document.documentElement.classList.remove('dark') + }, [isDark]) + + // Build per-file status: target is the "original" (we're merging INTO target), + // source is the "modified" (changes coming from source branch). + // Also marks files as 'C' (conflict) when listed in data.conflicts. + const filesWithStatus = useMemo(() => { + if (!data) + return [] as Array<{ + path: string + sourceContent: string + targetContent: string + baseContent: string | null + status: FileStatus + }> + + const sourceFiles = data.source.files.filter((f) => f.type === 'file') + const targetFiles = data.target.files.filter((f) => f.type === 'file') + const baseFiles = data.base?.files.filter((f) => f.type === 'file') ?? [] + + const sourceMap = new Map(sourceFiles.map((f) => [f.path, f.content])) + const targetMap = new Map(targetFiles.map((f) => [f.path, f.content])) + const baseMap = new Map(baseFiles.map((f) => [f.path, f.content])) + const allPaths = new Set([...sourceMap.keys(), ...targetMap.keys()]) + + const result: Array<{ + path: string + sourceContent: string + targetContent: string + baseContent: string | null + status: FileStatus + }> = [] + + for (const path of allPaths) { + if (path.startsWith('.git/')) continue + + const sourceContent = sourceMap.get(path) ?? '' + const targetContent = targetMap.get(path) ?? '' + const baseContent = baseMap.has(path) ? (baseMap.get(path) ?? null) : null + + let status: FileStatus + if (conflictedPaths.has(path)) { + status = resolvedFiles.has(path) ? 'R' : 'C' + } else if (!targetMap.has(path)) { + status = 'A' + } else if (!sourceMap.has(path)) { + status = 'D' + } else if (sourceContent !== targetContent) { + status = 'M' + } else { + status = 'U' + } + + result.push({ path, sourceContent, targetContent, baseContent, status }) + } + return result + }, [data, conflictedPaths, resolvedFiles]) + + const unresolvedConflictCount = useMemo( + () => filesWithStatus.filter((f) => f.status === 'C').length, + [filesWithStatus], + ) + + const changedFiles = filesWithStatus.filter((f) => f.status !== 'U') + const filteredFiles = searchQuery + ? changedFiles.filter((f) => f.path.toLowerCase().includes(searchQuery.toLowerCase())) + : changedFiles + const fileCount = changedFiles.length + const filteredFileCount = filteredFiles.length + const tree = buildTree(filteredFiles) + + const selected = filesWithStatus.find((f) => f.path === selectedFile) + + // Auto-expand all folders once on first render of changed files + useEffect(() => { + if (changedFiles.length > 0 && expandedFolders.size === 0) { + const allFolders = new Set() + for (const file of changedFiles) { + const parts = file.path.split('/') + for (let i = 1; i < parts.length; i++) { + allFolders.add(parts.slice(0, i).join('/')) + } + } + if (allFolders.size > 0) setExpandedFolders(allFolders) + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [changedFiles.length]) + + // Auto-select first changed file + useEffect(() => { + if (!selectedFile && changedFiles.length > 0) { + setSelectedFile(changedFiles[0].path) + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [changedFiles.length]) + + const toggleFolder = (path: string) => { + setExpandedFolders((prev) => { + const next = new Set(prev) + if (next.has(path)) next.delete(path) + else next.add(path) + return next + }) + } + + const goBack = onBack + const finishAndClose = onMerged + + const runMerge = async (shouldDelete: boolean) => { + if (!versionControl?.mergeBranches) return + + setMergeError(null) + setPostMergeError(null) + setShowMergeOptionsModal(false) + + // Only files the user actually marked resolved. Sending a half-edited one would merge + // content nobody approved. + const resolutionsToSend: Record = {} + for (const path of resolvedFiles) { + if (path in resolutions) resolutionsToSend[path] = resolutions[path] + } + + setIsMerging(true) + + try { + await versionControl.mergeBranches({ + projectId, + sourceBranch, + targetBranch, + commitMessage: commitMessage.trim() || undefined, + ...(Object.keys(resolutionsToSend).length > 0 ? { resolutions: resolutionsToSend } : {}), + }) + } catch (err: unknown) { + setIsMerging(false) + + if (err instanceof MergeConflictError) { + // The server still sees unresolved conflicts. Usually drift: the branches moved + // since this screen loaded, so reloading is the honest advice rather than letting + // the user re-press a button that will refuse again. + setMergeError( + `Conflicts remain in: ${err.conflictedFiles.join(', ')}. ` + + `The branches may have changed since you opened this page — please reload.`, + ) + return + } + + setMergeError(err instanceof Error ? err.message : 'Unknown error') + return + } + + // Merged. Deleting the source is a courtesy, so its failure must not read as the merge + // having failed — it warns and still lets the user close. + if (shouldDelete && sourceBranchEntry && canDeleteSource) { + setIsDeletingSource(true) + + try { + await versionControl.deleteBranch(projectId, sourceBranchEntry.id) + } catch (err: unknown) { + setIsDeletingSource(false) + setIsMerging(false) + setPostMergeError( + `Merge succeeded, but failed to delete source branch '${sourceBranch}': ${ + err instanceof Error ? err.message : 'unknown error' + }`, + ) + return + } + } + + setIsDeletingSource(false) + setIsMerging(false) + finishAndClose() + } + + if (isLoading) { + return ( +
+
+
+

Loading branch diff...

+
+
+ ) + } + + if (error) { + return ( +
+
+

Failed to load branch diff

+ +
+
+ ) + } + + return ( +
+ {/* Header */} +
+
+ +
+ + {sourceBranch} + + {targetBranch} + · + + {fileCount} file{fileCount !== 1 ? 's' : ''} changed + + {unresolvedConflictCount > 0 && ( + <> + · + + + {unresolvedConflictCount} conflict{unresolvedConflictCount !== 1 ? 's' : ''} + + + )} +
+
+
+ + +
+
+ + {/* Commit message editor (collapsible) */} + {showCommitMessageEdit && ( +
+ + setCommitMessage(e.target.value)} + placeholder={`Merge branch '${sourceBranch}' into ${targetBranch}`} + className='w-full rounded border border-neutral-200 bg-white px-2 py-1.5 text-xs text-neutral-900 focus:border-brand-light focus:outline-none focus:ring-1 focus:ring-brand-light dark:border-neutral-700 dark:bg-neutral-950 dark:text-neutral-100' + /> +
+ )} + + {mergeError && ( +
+

{mergeError}

+
+ )} + {postMergeError && ( +
+

{postMergeError}

+ +
+ )} + + {/* Main content */} +
+ + {/* File tree */} + +
+
+

+ {searchQuery + ? `${filteredFileCount} of ${fileCount} file${fileCount !== 1 ? 's' : ''}` + : `${fileCount} file${fileCount !== 1 ? 's' : ''}`} +

+
+ + setSearchQuery(e.target.value)} + placeholder='Search files...' + className='w-full rounded border border-neutral-200 bg-neutral-50 py-1 pl-6 pr-2 text-xs text-neutral-700 placeholder:text-neutral-400 focus:outline-none focus:ring-1 focus:ring-brand-light dark:border-neutral-700 dark:bg-neutral-900 dark:text-neutral-300 dark:placeholder:text-neutral-500' + /> +
+
+
+ {fileCount === 0 ? ( +

+ No differences. The branches are already in sync. +

+ ) : ( + tree.map((node) => ( + + )) + )} +
+
+
+ + + + {/* Diff viewer */} + +
+ {selected ? ( + selected.status === 'C' || selected.status === 'R' ? ( + isGraphicalFile(selected.path) ? ( + /* Graphical file conflict — pick one branch's version wholesale. + Per-rung/per-node semantic merge is Phase 2/3. */ + (() => { + const isResolved = resolvedFiles.has(selected.path) + const chosen = resolutions[selected.path] + const chosenSide = + chosen === selected.sourceContent + ? 'source' + : chosen === selected.targetContent + ? 'target' + : null + const pick = (side: 'source' | 'target') => { + const content = side === 'source' ? selected.sourceContent : selected.targetContent + setResolutions((prev) => ({ ...prev, [selected.path]: content })) + setResolvedFiles((prev) => new Set(prev).add(selected.path)) + } + const reset = () => { + setResolvedFiles((prev) => { + const next = new Set(prev) + next.delete(selected.path) + return next + }) + setResolutions((prev) => { + const next = { ...prev } + delete next[selected.path] + return next + }) + } + // Per-file conflict navigation — placeholder for when Phase 2 + // introduces per-rung conflict detection. For now each file + // shows as a single conflict (1/1). + const conflictIndex = 1 + const conflictTotal = 1 + const SideHeader = ({ side, branch }: { side: 'source' | 'target'; branch: string }) => ( +
+
+ + {branch} + + + {side} + +
+ +
+ ) + return ( +
+
+
+

+ {selected.path} +

+ {isResolved ? ( + + RESOLVED ({chosenSide}) + + ) : ( + + CONFLICT + + )} +
+
+ + + {conflictIndex}/{conflictTotal} + + +
+
+
+ {selected.baseContent !== null ? ( + <> + + + + + + ) : ( + + )} +
+
+ ) + })() + ) : ( + setResolutions((prev) => ({ ...prev, [selected.path]: content }))} + onMarkResolved={() => setResolvedFiles((prev) => new Set(prev).add(selected.path))} + onUnresolve={() => + setResolvedFiles((prev) => { + const next = new Set(prev) + next.delete(selected.path) + return next + }) + } + /> + ) + ) : ( + /* Non-conflict view: just show the diff (read-only) */ + <> +
+

+ {selected.path} +

+ + {FILE_STATUS_CONFIG[selected.status].label} + + + {targetBranch} ←{' '} + {sourceBranch} + +
+
+ {isGraphicalFile(selected.path) ? ( + + ) : ( + { + diffEditorRef.current = editor + }} + options={{ + readOnly: true, + minimap: { enabled: false }, + fontSize: 12, + scrollBeyondLastLine: false, + domReadOnly: true, + renderSideBySide: true, + originalEditable: false, + }} + /> + )} +
+ + ) + ) : ( +
+

+ {fileCount === 0 ? 'No changes between the selected branches.' : 'Select a file to view the diff'} +

+
+ )} +
+
+
+
+ + {/* Pre-merge: confirm and pick whether to delete the source branch */} + {showMergeOptionsModal && ( +
+
{ + if (!isMerging) setShowMergeOptionsModal(false) + }} + /> +
+

+ Merge {sourceBranch} into {targetBranch}? +

+

+ Choose whether to delete{' '} + {sourceBranch}{' '} + after the merge. The default branch can't be deleted. +

+
+ + + +
+
+
+ )} +
+ ) +} diff --git a/src/frontend/components/_features/[workspace]/branches/branch-status-bar.tsx b/src/frontend/components/_features/[workspace]/branches/branch-status-bar.tsx index be0ef04e3..3cf91f60f 100644 --- a/src/frontend/components/_features/[workspace]/branches/branch-status-bar.tsx +++ b/src/frontend/components/_features/[workspace]/branches/branch-status-bar.tsx @@ -1,8 +1,8 @@ -import { useCallback, useRef, useState } from 'react' +import { useCallback, useEffect, useRef, useState } from 'react' import type { Branch } from '../../../../../middleware/shared/ports/version-control-port' import { SwitchBranchCarryConflictError } from '../../../../../middleware/shared/ports/version-control-port' -import { useNavigation, useVersionControl } from '../../../../../middleware/shared/providers' +import { useCapabilities, useNavigation, useVersionControl } from '../../../../../middleware/shared/providers' import { useActiveBranch } from '../../../../hooks/use-active-branch' import { useOpenPLCStore } from '../../../../store' import { toast } from '../../../../utils/toast' @@ -18,6 +18,7 @@ type BranchStatusBarProps = { export function BranchStatusBar({ projectId, onBranchSwitch }: BranchStatusBarProps) { const versionControl = useVersionControl() + const caps = useCapabilities() const navigation = useNavigation() const checkIfAllFilesAreSaved = useOpenPLCStore((s) => s.fileActions.checkIfAllFilesAreSaved) const pendingChangesCount = useOpenPLCStore((s) => s.versionControl.pendingChangesCount) @@ -32,6 +33,54 @@ export function BranchStatusBar({ projectId, onBranchSwitch }: BranchStatusBarPr const [carryCheckState, setCarryCheckState] = useState('loading') const [conflictedFiles, setConflictedFiles] = useState([]) + /** + * Reconcile the remembered branch against the ones that actually exist. + * + * The active branch is client state, kept in `localStorage` per project, and nothing used + * to check it was still real. A branch deleted anywhere else — the web editor, another + * machine, or a merge that removed its source — left this bar naming it indefinitely, and + * worse: the history section passes the name straight into `listCommits({ branch })`, so a + * stale name meant querying a branch the server no longer has. + * + * Falling back to the default branch is the honest answer, because that is where the + * server puts a working tree whose branch went away. + * + * WHAT THIS DOES NOT FIX. If the remembered branch still exists but the server's checkout + * moved to a different one, the two remain out of step and this cannot tell: the API + * reports `defaultBranch` and each branch's head, but never which branch is checked out. + * Closing that gap needs the backend to say so — forcing a `switchBranch` on open instead + * would be a write on every project open, and with `discard` it could throw away + * server-side edits nobody asked to lose. + */ + useEffect(() => { + if (!versionControl || !projectId) { + return + } + + let alive = true + + versionControl + .listBranches(projectId) + .then(({ branches }) => { + // An empty list means the request told us nothing useful, not that every branch is + // gone — leaving the remembered name alone is safer than resetting on a blank answer. + if (!alive || branches.length === 0 || branches.some((branch) => branch.name === activeBranchName)) { + return + } + + const fallback = branches.find((branch) => branch.isDefault) ?? branches[0] + + setActiveBranch(fallback.name) + }) + .catch(() => { + // Offline or denied. The remembered name is all there is, so it stays. + }) + + return () => { + alive = false + } + }, [projectId, versionControl, activeBranchName, setActiveBranch]) + const doSwitch = useCallback( async (branch: Branch, strategy: 'discard' | 'carry' = 'discard') => { if (!versionControl) return @@ -212,7 +261,10 @@ export function BranchStatusBar({ projectId, onBranchSwitch }: BranchStatusBarPr onClose={() => setShowSwitcher(false)} onSelect={handleSelect} onDelete={handleDelete} - onMerge={handleMerge} + // Withheld where there is no screen to reach. Both builds have one today, so this + // passes through; the guard remains because handing the entry a callback with no + // destination is what once closed the open project instead of merging anything. + onMerge={caps.hasBranchMerge ? handleMerge : undefined} /> void onSelect: (branch: Branch) => void onDelete: (branch: Branch) => void - onMerge: (branch: Branch) => void + /** + * Absent on a build with no merge screen, and the entry is then not rendered at all. + * Rendering it disabled would still be a promise this platform cannot keep. + */ + onMerge?: (branch: Branch) => void } export function BranchSwitcherPopover({ @@ -210,37 +214,39 @@ export function BranchSwitcherPopover({ onCloseAutoFocus={(e) => e.preventDefault()} className='z-[60] min-w-[140px] overflow-hidden rounded-md border border-neutral-200 bg-white py-1 shadow-lg dark:border-neutral-700 dark:bg-neutral-900' > - { - e.preventDefault() - if (isActive) return - onMerge(branch) - handleClose() - }} - title={isActive ? 'Cannot merge a branch into itself' : undefined} - className={cn( - 'flex select-none items-center gap-2 px-3 py-1.5 text-xs outline-none', - isActive - ? 'cursor-not-allowed text-neutral-400 dark:text-neutral-600' - : 'cursor-pointer text-neutral-700 hover:bg-neutral-100 dark:text-neutral-300 dark:hover:bg-neutral-800', - )} - > - { + e.preventDefault() + if (isActive) return + onMerge(branch) + handleClose() + }} + title={isActive ? 'Cannot merge a branch into itself' : undefined} className={cn( - 'h-3.5 w-3.5', - isActive ? 'text-neutral-400 dark:text-neutral-600' : 'text-blue-500', + 'flex select-none items-center gap-2 px-3 py-1.5 text-xs outline-none', + isActive + ? 'cursor-not-allowed text-neutral-400 dark:text-neutral-600' + : 'cursor-pointer text-neutral-700 hover:bg-neutral-100 dark:text-neutral-300 dark:hover:bg-neutral-800', )} - viewBox='0 0 16 16' - fill='currentColor' > - - - - Merge {branch.name} into{' '} - {currentBranchName} - - + + + + + Merge {branch.name} into{' '} + {currentBranchName} + + + )} { diff --git a/src/frontend/components/_features/[workspace]/branches/merge-text-conflict-resolver.tsx b/src/frontend/components/_features/[workspace]/branches/merge-text-conflict-resolver.tsx new file mode 100644 index 000000000..8372a1ca1 --- /dev/null +++ b/src/frontend/components/_features/[workspace]/branches/merge-text-conflict-resolver.tsx @@ -0,0 +1,245 @@ +import { DiffEditor, Editor } from '@monaco-editor/react' +import { ResizableHandle, ResizablePanel, ResizablePanelGroup } from '@root/frontend/components/_organisms/panel' +import { cn } from '@root/frontend/utils/cn' +import { ArrowLeftRight, Check, Minus } from 'lucide-react' +import { useEffect, useMemo } from 'react' + +import { useDiffEditorTeardown, useDiffModelPaths } from '../editor/diff-viewer/use-diff-editor-teardown' + +const CONFLICT_MARKER_RE = /^(<<<<<<<|=======|>>>>>>>)/m + +function getLanguageFromPath(path: string): string { + const ext = path.split('.').pop()?.toLowerCase() + switch (ext) { + case 'json': + return 'json' + case 'st': + case 'il': + case 'sfc': + return 'st' + case 'py': + return 'python' + case 'c': + return 'c' + case 'cpp': + return 'cpp' + default: + return 'plaintext' + } +} + +/** + * Build the initial resolution content with git-style conflict markers, + * pre-populated for the user to edit. + */ +function buildInitialResolution( + _filePath: string, + sourceContent: string, + targetContent: string, + _baseContent: string | null, + sourceBranch: string, + targetBranch: string, +): string { + // Simple line-based conflict markers (real git would do hunks; this is a + // pragmatic version that gives the user both versions to edit freely). + return [ + `<<<<<<< ${sourceBranch} (source)`, + sourceContent.trimEnd(), + '=======', + targetContent.trimEnd(), + `>>>>>>> ${targetBranch} (target)`, + '', + ].join('\n') + // Note: baseContent could be used in a 3-way diff display; not needed for + // the editable resolution panel itself. +} + +type TextConflictResolverProps = { + filePath: string + sourceContent: string + targetContent: string + baseContent: string | null + sourceBranch: string + targetBranch: string + /** The current resolution content (controlled). */ + resolution: string | undefined + isResolved: boolean + isDark: boolean + onChange: (content: string) => void + onMarkResolved: () => void + onUnresolve: () => void +} + +export function TextConflictResolver({ + filePath, + sourceContent, + targetContent, + baseContent, + sourceBranch, + targetBranch, + resolution, + isResolved, + isDark, + onChange, + onMarkResolved, + onUnresolve, +}: TextConflictResolverProps) { + // Same reversed teardown as everywhere Monaco is mounted directly — see the hook. + const diffEditorRef = useDiffEditorTeardown() + const diffModelPaths = useDiffModelPaths() + + const language = getLanguageFromPath(filePath) + + const initialResolution = useMemo( + () => buildInitialResolution(filePath, sourceContent, targetContent, baseContent, sourceBranch, targetBranch), + [filePath, sourceContent, targetContent, baseContent, sourceBranch, targetBranch], + ) + + // Initialize the resolution if not set yet + useEffect(() => { + if (resolution === undefined) { + onChange(initialResolution) + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [filePath]) + + const currentValue = resolution ?? initialResolution + const hasMarkers = CONFLICT_MARKER_RE.test(currentValue) + + return ( +
+ {/* Header bar */} +
+
+

{filePath}

+ {isResolved ? ( + + RESOLVED + + ) : ( + + CONFLICT + + )} +
+
+ + + {isResolved ? ( + + ) : ( + + )} +
+
+ + {/* Top: side-by-side source vs target (read-only diff) */} + + +
+
+ + {sourceBranch} (source) + + + + {targetBranch} (target) + +
+
+ { + diffEditorRef.current = editor + }} + options={{ + readOnly: true, + minimap: { enabled: false }, + fontSize: 12, + scrollBeyondLastLine: false, + renderSideBySide: true, + originalEditable: false, + }} + /> +
+
+
+ + + + {/* Bottom: editable resolution */} + +
+
+ YOUR RESOLUTION (edit freely) + {hasMarkers && ( + + ⚠ Remove all `<<<<<<<`, `=======`, `>>>>>>>` markers to + enable "Mark as resolved" + + )} +
+
+ onChange(v ?? '')} + language={language} + theme={isDark ? 'vs-dark' : 'vs'} + options={{ + readOnly: isResolved, + minimap: { enabled: false }, + fontSize: 12, + scrollBeyondLastLine: false, + }} + className={cn(isResolved && 'opacity-80')} + /> +
+
+
+
+
+ ) +} diff --git a/src/frontend/components/_features/[workspace]/commit-history/index.tsx b/src/frontend/components/_features/[workspace]/commit-history/index.tsx new file mode 100644 index 000000000..170477c1e --- /dev/null +++ b/src/frontend/components/_features/[workspace]/commit-history/index.tsx @@ -0,0 +1,496 @@ +/** + * One commit, file by file, with the diff for whichever file is selected. + * + * Reached from the source-control panel's "View all files". It is a whole screen rather + * than a panel because that is what the content needs: a graphical diff of a ladder or + * FBD program next to its previous version does not fit in a sidebar. + * + * PLATFORM-FREE ON PURPOSE. This owns the tree, the statuses, the search and the restore + * flow, and it takes `onBack`/`onRestored` instead of navigating. The web wraps it in a + * router page at `/history`; the desktop, which has no router, renders it as a full-screen + * layer over the workspace. Both get the same screen from the same code — which is the + * only way the two stay identical as this grows. + * + * `h-full w-full` rather than `h-screen w-screen` for that reason: the viewport is the + * host's business, and a screen-sized child inside a layered container overflows it. + */ + +import { ArrowLeft, File, Folder, FolderOpen, RotateCcw, Search } from 'lucide-react' +import { useEffect, useMemo, useState } from 'react' + +import type { CommitFile, CommitInfo } from '../../../../../middleware/shared/ports/version-control-port' +import { useTheme, useVersionControl } from '../../../../../middleware/shared/providers' +import { cn } from '../../../../utils/cn' +import { isSystemFile } from '../../../../utils/system-files' +import { ResizableHandle, ResizablePanel, ResizablePanelGroup } from '../../../_organisms/panel' +import { FileDiffView, isGraphicalFile } from '../editor/diff-viewer' +import { RestoreConfirmationModal } from '../source-control/modals/restore-confirmation-modal' + +// --------------------------------------------------------------------------- +// File tree types & helpers +// --------------------------------------------------------------------------- + +type FileStatus = 'A' | 'M' | 'D' | 'U' + +const FILE_STATUS_CONFIG: Record = { + A: { label: 'Added', color: 'text-green-500' }, + M: { label: 'Modified', color: 'text-yellow-500' }, + D: { label: 'Deleted', color: 'text-red-500' }, + U: { label: 'Unchanged', color: 'text-neutral-400' }, +} + +type FileTreeNode = { + name: string + path: string + type: 'file' | 'folder' + status?: FileStatus + children?: FileTreeNode[] +} + +function buildTree(files: { path: string; content: string; status?: FileStatus }[]): FileTreeNode[] { + const root: FileTreeNode[] = [] + + for (const file of files) { + const parts = file.path.split('/').filter(Boolean) + if (parts.length === 0) continue + let current = root + + for (let i = 0; i < parts.length; i++) { + const name = parts[i] + const isFile = i === parts.length - 1 + + let existing = current.find((n) => n.name === name) + if (!existing) { + existing = { + name, + path: parts.slice(0, i + 1).join('/'), + type: isFile ? 'file' : 'folder', + status: isFile ? file.status : undefined, + children: isFile ? undefined : [], + } + current.push(existing) + } + if (!isFile) { + if (!existing.children) { + existing.children = [] + existing.type = 'folder' + } + current = existing.children + } + } + } + + const sortNodes = (nodes: FileTreeNode[]) => { + nodes.sort((a, b) => { + if (a.type !== b.type) return a.type === 'folder' ? -1 : 1 + return a.name.localeCompare(b.name) + }) + for (const node of nodes) { + if (node.children) sortNodes(node.children) + } + } + sortNodes(root) + return root +} + +// --------------------------------------------------------------------------- +// File tree item +// --------------------------------------------------------------------------- + +function FileStatusBadge({ status }: { status: FileStatus }) { + const config = FILE_STATUS_CONFIG[status] + return ( + + {status} + + ) +} + +function FileTreeItem({ + node, + depth, + selectedPath, + onSelect, + expandedFolders, + onToggleFolder, +}: { + node: FileTreeNode + depth: number + selectedPath: string | null + onSelect: (path: string) => void + expandedFolders: Set + onToggleFolder: (path: string) => void +}) { + const isExpanded = expandedFolders.has(node.path) + const isSelected = node.path === selectedPath + + if (node.type === 'folder') { + return ( +
+ + {isExpanded && + node.children?.map((child) => ( + + ))} +
+ ) + } + + return ( + + ) +} + +// --------------------------------------------------------------------------- +// History page +// --------------------------------------------------------------------------- + +export type CommitHistoryViewProps = { + projectId: string + commitHash: string + /** Pre-selected file, so clicking one in the panel lands on its diff. */ + initialFile?: string + /** Leave the screen. The host decides what that means. */ + onBack: () => void + /** + * A restore landed, so the project on disk no longer matches what is on screen. The + * host has to reload it — this component cannot, and pretending otherwise would leave + * the user editing a stale copy. + */ + onRestored: () => void +} + +export function CommitHistoryView({ projectId, commitHash, initialFile, onBack, onRestored }: CommitHistoryViewProps) { + const versionControl = useVersionControl() + const themePort = useTheme() + + const [selectedFile, setSelectedFile] = useState(initialFile ?? null) + const [expandedFolders, setExpandedFolders] = useState>(new Set()) + const [showRestoreModal, setShowRestoreModal] = useState(false) + const [isRestoring, setIsRestoring] = useState(false) + const [searchQuery, setSearchQuery] = useState('') + + const [isLoading, setIsLoading] = useState(true) + const [error, setError] = useState(null) + const [files, setFiles] = useState([]) + const [parentFiles, setParentFiles] = useState([]) + const [commit, setCommit] = useState(null) + + const isDark = themePort.getCurrentTheme() === 'dark' + + // Fetch commit files via port + useEffect(() => { + if (!versionControl) return + setIsLoading(true) + setError(null) + + versionControl + .getCommitFiles(projectId, commitHash) + .then((data) => { + setFiles(data.files) + setParentFiles(data.parentFiles) + setCommit(data.commit) + }) + .catch((err) => { + setError(err instanceof Error ? err.message : 'Failed to load commit files') + }) + .finally(() => setIsLoading(false)) + }, [projectId, commitHash, versionControl]) + + const parentFileMap = useMemo(() => new Map(parentFiles.map((f) => [f.path, f.content])), [parentFiles]) + + const filesWithStatus = useMemo(() => { + const currentPaths = new Set(files.map((f) => f.path)) + const parentPaths = new Set(parentFiles.map((f) => f.path)) + + const result: { path: string; content: string; status: FileStatus }[] = [] + + for (const file of files) { + const parentContent = parentFileMap.get(file.path) + let status: FileStatus + if (!parentPaths.has(file.path)) { + status = 'A' + } else if (parentContent !== file.content) { + // Bytes differ. For graphical files, the difference may be only transient + // UI state (selectedNodes, dragging, etc.) that leaked into older commits. + // Run the semantic diff: if nodes/edges/variables match, treat as unchanged. + if (isGraphicalFile(file.path) && versionControl && parentContent !== undefined) { + const semantic = versionControl.computeGraphicalDiff(parentContent, file.content, file.path) + status = semantic.changedIndexes.length === 0 && semantic.variableDiff.length === 0 ? 'U' : 'M' + } else { + status = 'M' + } + } else { + status = 'U' + } + result.push({ ...file, status }) + } + + for (const pf of parentFiles) { + if (!currentPaths.has(pf.path)) { + result.push({ path: pf.path, content: '', status: 'D' }) + } + } + + return result + }, [files, parentFiles, parentFileMap, versionControl]) + + const changedFiles = useMemo( + () => filesWithStatus.filter((f) => f.status !== 'U' && !isSystemFile(f.path)), + [filesWithStatus], + ) + const filteredFiles = useMemo( + () => + searchQuery ? changedFiles.filter((f) => f.path.toLowerCase().includes(searchQuery.toLowerCase())) : changedFiles, + [changedFiles, searchQuery], + ) + const tree = useMemo(() => buildTree(filteredFiles), [filteredFiles]) + + const selectedCurrent = files.find((f) => f.path === selectedFile)?.content ?? '' + const selectedOriginal = parentFileMap.get(selectedFile ?? '') ?? '' + const selectedStatus = filesWithStatus.find((f) => f.path === selectedFile)?.status + + // Auto-expand folders on first load + useEffect(() => { + if (filesWithStatus.length > 0 && expandedFolders.size === 0) { + const allFolders = new Set() + for (const file of filesWithStatus) { + const parts = file.path.split('/') + for (let i = 1; i < parts.length; i++) { + allFolders.add(parts.slice(0, i).join('/')) + } + } + if (allFolders.size > 0) setExpandedFolders(allFolders) + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [filesWithStatus]) + + const toggleFolder = (path: string) => { + setExpandedFolders((prev) => { + const next = new Set(prev) + if (next.has(path)) next.delete(path) + else next.add(path) + return next + }) + } + + const handleRestore = () => { + if (!versionControl) return + setIsRestoring(true) + versionControl + .restoreCommit(projectId, commitHash) + .then(() => { + setShowRestoreModal(false) + onRestored() + }) + .catch(() => setIsRestoring(false)) + } + + const goBack = onBack + + if (isLoading) { + return ( +
+
+
+

Loading commit files...

+
+
+ ) + } + + if (error) { + return ( +
+
+

Failed to load commit files

+ +
+
+ ) + } + + const shortHash = commitHash.slice(0, 7) + + return ( +
+ {/* Header */} +
+
+ +
+ {shortHash} + {commit && ( + <> + · + {commit.message} + · + + {new Date(commit.timestamp).toLocaleDateString('en-US', { + month: 'long', + day: 'numeric', + year: 'numeric', + })} + + + )} +
+
+ +
+ + {/* Main content */} +
+ + {/* File tree */} + +
+
+

+ {searchQuery + ? `${filteredFiles.length} of ${changedFiles.length} file${changedFiles.length !== 1 ? 's' : ''}` + : `${changedFiles.length} file${changedFiles.length !== 1 ? 's' : ''}`} +

+
+ + setSearchQuery(e.target.value)} + placeholder='Search files...' + className='w-full rounded border border-neutral-200 bg-neutral-50 py-1 pl-6 pr-2 text-xs text-neutral-700 placeholder:text-neutral-400 focus:outline-none focus:ring-1 focus:ring-brand-light dark:border-neutral-700 dark:bg-neutral-900 dark:text-neutral-300 dark:placeholder:text-neutral-500' + /> +
+
+
+ {tree.map((node) => ( + + ))} +
+
+
+ + + + {/* Diff viewer */} + +
+ {selectedFile ? ( + <> +
+

+ {selectedFile} +

+ {selectedStatus && ( + + {FILE_STATUS_CONFIG[selectedStatus].label} + + )} +
+
+ +
+ + ) : ( +
+

Select a file to view the diff

+
+ )} +
+
+
+
+ + setShowRestoreModal(false)} + /> +
+ ) +} diff --git a/src/frontend/components/_features/[workspace]/editor/diff-viewer/file-diff-view.tsx b/src/frontend/components/_features/[workspace]/editor/diff-viewer/file-diff-view.tsx index 134256630..42829d8ff 100644 --- a/src/frontend/components/_features/[workspace]/editor/diff-viewer/file-diff-view.tsx +++ b/src/frontend/components/_features/[workspace]/editor/diff-viewer/file-diff-view.tsx @@ -8,6 +8,7 @@ import { DiffEditor } from '@monaco-editor/react' import { GraphicalDiffViewer, isGraphicalFile } from './graphical-diff-viewer' +import { useDiffEditorTeardown, useDiffModelPaths } from './use-diff-editor-teardown' export { isGraphicalFile } @@ -66,6 +67,10 @@ type FileDiffViewProps = { } export function FileDiffView({ filePath, original, current, isDark }: FileDiffViewProps) { + const editorRef = useDiffEditorTeardown() + + const modelPaths = useDiffModelPaths() + if (isGraphicalFile(filePath)) { return ( @@ -78,6 +83,13 @@ export function FileDiffView({ filePath, original, current, isDark }: FileDiffVi modified={formatContentForDisplay(filePath, current)} language={getLanguageFromPath(filePath)} theme={isDark ? 'vs-dark' : 'vs'} + originalModelPath={modelPaths.original} + modifiedModelPath={modelPaths.modified} + keepCurrentOriginalModel + keepCurrentModifiedModel + onMount={(editor) => { + editorRef.current = editor + }} options={{ readOnly: true, minimap: { enabled: false }, diff --git a/src/frontend/components/_features/[workspace]/editor/diff-viewer/use-diff-editor-teardown.ts b/src/frontend/components/_features/[workspace]/editor/diff-viewer/use-diff-editor-teardown.ts new file mode 100644 index 000000000..bba52a0e1 --- /dev/null +++ b/src/frontend/components/_features/[workspace]/editor/diff-viewer/use-diff-editor-teardown.ts @@ -0,0 +1,75 @@ +/** + * Tearing a Monaco diff editor down in the order Monaco requires. + * + * `@monaco-editor/react` (4.7) does it the other way round: its cleanup disposes the two + * text models and only then the widget still holding them. Monaco answers with an UNCAUGHT + * error — "TextModel got disposed before DiffEditorWidget model got reset" — the instant a + * diff unmounts. + * + * WHY THIS LIVES IN ITS OWN MODULE. It began inline in `FileDiffView`, which was enough + * until the branch merge screen arrived: that screen mounts `DiffEditor` directly, twice, + * and so brought the crash straight back through a path the first fix never covered. One + * copy per Monaco call site is how that happens again, so there is one copy here and every + * call site uses it. + * + * Pair it with `keepCurrentOriginalModel` / `keepCurrentModifiedModel` on the editor — + * those stop the library disposing anything — and with `diffModelPaths()` so instances do + * not share models. + */ + +import type { editor as MonacoEditor } from 'monaco-editor' +import { useEffect, useId, useRef } from 'react' + +export function useDiffEditorTeardown() { + const editorRef = useRef(null) + + useEffect( + () => () => { + const editor = editorRef.current + + if (!editor) { + return + } + + editorRef.current = null + + // Read the models before touching the widget: after `setModel(null)` there is nothing + // left to ask, and these are the objects that have to be disposed. + const models = editor.getModel() + + try { + editor.setModel(null) + } catch { + // Already disposed by the library's own cleanup. Nothing to release, and the models + // below still need disposing. Order-independent on purpose: whether React reaches + // this cleanup before the library's is its own business. + } + + models?.original.dispose() + models?.modified.dispose() + }, + [], + ) + + return editorRef +} + +/** + * A model URI pair unique to one mounted editor, and stable for its lifetime. + * + * Unique because the library derives the URI from these props and defaults them to the + * empty string, so every diff editor in the app would otherwise share one pair of models — + * reachable now that a merge screen can sit over a workspace whose own diff is mounted. + * + * Stable because the library only creates a model when it cannot find one at the URI, and + * hands back the existing one otherwise, with its old content. A changing path would + * resurrect a stale model; a fixed path lets the library's own value sync update the text. + * + * `useId` is punctuated (`:r0:`) and a colon inside the authority of `inmemory://…` reads + * as a port, so it is stripped to letters and digits. + */ +export function useDiffModelPaths(): { original: string; modified: string } { + const id = useId().replace(/[^a-zA-Z0-9]/g, '') + + return { original: `inmemory://diff${id}/original`, modified: `inmemory://diff${id}/modified` } +} diff --git a/src/frontend/components/_organisms/display-recent-projects/index.tsx b/src/frontend/components/_organisms/display-recent-projects/index.tsx index da779cf00..5bc2d0e83 100644 --- a/src/frontend/components/_organisms/display-recent-projects/index.tsx +++ b/src/frontend/components/_organisms/display-recent-projects/index.tsx @@ -1,17 +1,28 @@ import * as DropdownMenu from '@radix-ui/react-dropdown-menu' +import { CloudUpload } from 'lucide-react' import { ComponentProps, useEffect, useRef, useState } from 'react' -import { useProject } from '../../../../middleware/shared/providers' +import { useCapabilities, useEdgeAccountPort, useProject } from '../../../../middleware/shared/providers' +import { useEdgeAccount } from '../../../hooks/use-edge-account' import { useOpenPLCStore } from '../../../store' import { cn } from '../../../utils/cn' import { File } from '../../_atoms/file' import { toast } from '../../_features/[app]/toast/use-toast' +import { UploadToCloudModal } from '../../_features/[start]/upload-to-cloud' export type IDisplayRecentProjectProps = ComponentProps<'section'> & { searchNameFilterValue: string + /** + * A local project was published to Autonomy Edge. + * + * Reported upward because the list it belongs in is a sibling section, and the start + * screen is the only thing that knows both exist. Without it the newly published + * project was missing from the cloud list until the screen was rebuilt. + */ + onProjectUploaded?: () => void } -const DisplayRecentProjects = ({ searchNameFilterValue, ...props }: IDisplayRecentProjectProps) => { +const DisplayRecentProjects = ({ searchNameFilterValue, onProjectUploaded, ...props }: IDisplayRecentProjectProps) => { const { workspace: { recent }, workspaceActions: { setRecent }, @@ -20,6 +31,22 @@ const DisplayRecentProjects = ({ searchNameFilterValue, ...props }: IDisplayRece } = useOpenPLCStore() const project = useProject() + const caps = useCapabilities() + const edgeAccount = useEdgeAccountPort() + + /** + * Publishing is offered only to someone who is actually signed in, which is why this + * asks who that is rather than inferring it. A menu entry that opens a dialog only to + * say "sign in first" is a worse answer than not offering the entry. + * + * `canPublish` also requires the platform to implement the call: the web build has no + * local projects to publish, and this component is shared with it. + */ + const { status: accountStatus } = useEdgeAccount(caps.hasEdgeAccount, edgeAccount) + const canPublish = accountStatus === 'signed-in' && project.uploadProjectToCloud !== undefined + + /** The project whose upload dialog is open, if any. */ + const [projectToUpload, setProjectToUpload] = useState<{ name: string; path: string } | null>(null) const [recentProjects, setRecentProjects] = useState(recent) const [projectTimes, setProjectTimes] = useState<{ [key: string]: string }>({}) @@ -177,6 +204,25 @@ const DisplayRecentProjects = ({ searchNameFilterValue, ...props }: IDisplayRece onCloseAutoFocus={(e) => e.preventDefault()} className='z-[60] min-w-[180px] overflow-hidden rounded-md border border-neutral-200 bg-white py-1 shadow-lg dark:border-neutral-700 dark:bg-neutral-900' > + {canPublish && ( + setProjectToUpload({ name: proj.name, path: proj.path })} + className={cn( + 'flex cursor-pointer select-none items-center gap-2 px-3 py-1.5 text-xs outline-none', + // Brand blue for the label as well as the glyph: the other two + // entries manage the local copy, and this one is the only entry + // that reaches Autonomy Edge. Reading as one blue unit is what + // says so. + // `blue-500` for the tint: `text-brand` is fine, but the brand token is a + // `var()` holding a hex and Tailwind 3 cannot apply `/5` to it, so the + // hover would simply not paint. Same colour either way. + 'text-brand hover:bg-blue-500/5 dark:hover:bg-blue-500/10', + )} + > + + Upload to Cloud + + )} void handleRemoveFromList(proj.path)} className={cn( @@ -207,6 +253,31 @@ const DisplayRecentProjects = ({ searchNameFilterValue, ...props }: IDisplayRece
))}
+ + {/* One dialog for the whole list rather than one per card: only a single upload can + be in flight, and mounting a modal per project would have every card ask Edge for + the folder list. */} + {projectToUpload && ( + { + if (!next) setProjectToUpload(null) + }} + projectPath={projectToUpload.path} + projectName={projectToUpload.name} + onUploaded={() => { + toast({ + title: 'Uploaded to Autonomy Edge', + description: `${projectToUpload.name} is now on your account. The copy on this computer is unchanged.`, + variant: 'default', + }) + setProjectToUpload(null) + // Before the toast is even read: the project belongs at the top of the cloud + // list, and seeing it land there is the confirmation that matters. + onProjectUploaded?.() + }} + /> + )} ) } diff --git a/src/frontend/components/_organisms/edge-account-menu/index.tsx b/src/frontend/components/_organisms/edge-account-menu/index.tsx index 10bf4f59d..183d1c67c 100644 --- a/src/frontend/components/_organisms/edge-account-menu/index.tsx +++ b/src/frontend/components/_organisms/edge-account-menu/index.tsx @@ -1,7 +1,9 @@ import * as DropdownMenu from '@radix-ui/react-dropdown-menu' import { LayoutDashboard, LogOut, Settings, User } from 'lucide-react' +import type { ReactNode } from 'react' import type { EdgeUser } from '../../../../middleware/shared/ports/edge-account-port' +import { cn } from '../../../utils/cn' import { EdgeAvatar } from '../../_atoms/edge-avatar' /** @@ -34,6 +36,26 @@ interface EdgeAccountMenuProps { * environment exists. */ edgeBaseUrl: string + /** + * Size/shape for the trigger avatar. + * + * The activity bar wants the default — it is a column of its own and the avatar is + * the whole control. The start screen is a text menu whose icons are 20px, and an + * avatar that ignores that reads as misaligned rather than prominent. + */ + avatarClassName?: string + /** + * Rendered inside the trigger, after the avatar. + * + * Exists so the whole row can be the trigger rather than just the avatar. In the + * activity bar a bare avatar is an obvious target, because it is the only thing in + * its column. In a text menu the name sits right beside it and is what a person + * actually aims at — leaving that outside the trigger means clicking the obvious + * place does nothing. + */ + label?: ReactNode + /** Trigger geometry, for a caller that needs it to match a row of other controls. */ + triggerClassName?: string /** * Which way the menu opens. Defaults to `right` because this lives in the * activity bar, a ~48px strip — a menu dropping straight down would be clipped @@ -47,7 +69,16 @@ const ITEM_CLASSES = const ICON_CLASSES = 'h-4 w-4 shrink-0 text-neutral-500 dark:text-neutral-400' const LABEL_CLASSES = 'text-sm text-neutral-900 dark:text-neutral-100' -const EdgeAccountMenu = ({ user, planCaption, onSignOut, edgeBaseUrl, side = 'right' }: EdgeAccountMenuProps) => { +const EdgeAccountMenu = ({ + user, + planCaption, + onSignOut, + edgeBaseUrl, + side = 'right', + avatarClassName, + label, + triggerClassName, +}: EdgeAccountMenuProps) => { const edgeBase = edgeBaseUrl // Same destinations Edge's own dropdown navigates to. `/profile` rather than // `/{username}`: the latter is the public profile page, not the account one the @@ -62,14 +93,19 @@ const EdgeAccountMenu = ({ user, planCaption, onSignOut, edgeBaseUrl, side = 'ri diff --git a/src/frontend/components/_organisms/edge-sign-in-modal/index.tsx b/src/frontend/components/_organisms/edge-sign-in-modal/index.tsx index 68b7f6b10..e99cdbc0a 100644 --- a/src/frontend/components/_organisms/edge-sign-in-modal/index.tsx +++ b/src/frontend/components/_organisms/edge-sign-in-modal/index.tsx @@ -36,6 +36,15 @@ type SignInValues = z.infer interface EdgeSignInModalProps { open: boolean + /** + * Requests a close, on a build where this dialog is dismissible. + * + * Absent where an account is required: there the dialog IS the screen, and letting + * it close would leave the user looking at an editor with no project in it and no + * way back. Present where signing in is optional, because a dialog the user opened + * has to be one they can also close. + */ + onOpenChange?: (open: boolean) => void onSignedIn: () => void /** * The platform's Edge account port: signing in, the provider list and where @@ -98,7 +107,7 @@ type FormState = { kind: 'idle' } | { kind: 'error'; message: string } | { kind: const FIELD_CLASSES = 'w-full rounded-lg border border-neutral-300 bg-transparent py-2 pr-9 text-sm text-neutral-900 outline-none placeholder:text-neutral-400 focus:border-brand dark:border-neutral-700 dark:text-neutral-100' -const EdgeSignInModal = ({ open, onSignedIn, account, reason = 'signed-out' }: EdgeSignInModalProps) => { +const EdgeSignInModal = ({ open, onOpenChange, onSignedIn, account, reason = 'signed-out' }: EdgeSignInModalProps) => { const copy = REASON_COPY[reason] const [formState, setFormState] = useState({ kind: 'idle' }) const [submitting, setSubmitting] = useState(false) @@ -156,7 +165,7 @@ const EdgeSignInModal = ({ open, onSignedIn, account, reason = 'signed-out' }: E } return ( - + {/* `h-fit`, not `h-auto`: ModalContent hardcodes `h-[500px]` (so the provider row rendered outside the dialog's border) AND positions itself with `fixed inset-0 m-auto` — under which `height: auto` means "stretch from diff --git a/src/frontend/components/_organisms/workspace-activity-bar/index.tsx b/src/frontend/components/_organisms/workspace-activity-bar/index.tsx index dccb2dcf4..9209fe393 100644 --- a/src/frontend/components/_organisms/workspace-activity-bar/index.tsx +++ b/src/frontend/components/_organisms/workspace-activity-bar/index.tsx @@ -1,11 +1,12 @@ -import { Files, GitBranch } from 'lucide-react' -import { useCallback } from 'react' +import { Files, GitBranch, LogIn } from 'lucide-react' +import { useCallback, useState } from 'react' import { useCapabilities, useEdgeAccountPort, useNavigation } from '../../../../middleware/shared/providers' import { useEdgeAccount } from '../../../hooks/use-edge-account' import { useIsNinetiesTheme } from '../../../hooks/use-nineties-theme' import { useOpenPLCStore } from '../../../store' import { cn } from '../../../utils/cn' +import { ActivityBarButton } from '../../_atoms/buttons/activity-bar' import { RetroExplorer, RetroSourceControl } from '../../_atoms/retro-icons' import { DividerActivityBar } from '../../_atoms/workspace-activity-bar/divider' import { ExitButton } from '../../_molecules/workspace-activity-bar/default/exit' @@ -44,6 +45,8 @@ export const WorkspaceActivityBar = ({ defaultActivityBar, explorer, sourceContr refresh: refreshAccount, signOut: signOutOfAccount, } = useEdgeAccount(caps.hasEdgeAccount, edgeAccount) + /** Whether the user asked for the sign-in dialog, on a build that does not force it. */ + const [signInDialogOpen, setSignInDialogOpen] = useState(false) const editor = useOpenPLCStore(useCallback((s) => s.editor, [])) const { closeProject } = useOpenPLCStore(useCallback((s) => s.sharedWorkspaceActions, [])) const navigation = useNavigation() @@ -134,12 +137,15 @@ export const WorkspaceActivityBar = ({ defaultActivityBar, explorer, sourceContr same reasoning that keeps it out of the toolbox above the divider. The bottom padding follows the account slot instead of being fixed. Where - there is no slot — the desktop editor, which mirrors this file and sets - `hasEdgeAccount` to false — the exit arrow is still the last thing in the - bar and keeps the `pb-10` it has always had. Making room for a menu that - build never renders had moved it ~28px down the activity bar, which is a - visible change to an existing control for no reason. Where the account - does render it is the last thing, and it wants the smaller gap. */} + there is no slot — a build with no Edge account, such as autonomy-node — the + exit arrow is still the last thing in the bar and keeps the `pb-10` it has + always had. Making room for a menu that build never renders had moved it + ~28px down the activity bar, a visible change to an existing control for no + reason. Where the account does render it is the last thing, and it wants the + smaller gap. + + The slot is occupied on both sign-in states, so the arrow does not move when + someone signs in or out. */}
@@ -160,13 +166,40 @@ export const WorkspaceActivityBar = ({ defaultActivityBar, explorer, sourceContr }} /> )} + + {/* The same slot, for a build that does not demand an account: the way in is + offered rather than imposed. Never rendered where the dialog is already + opening itself, so the web build is untouched. */} + {caps.hasEdgeAccount && edgeAccount && !caps.requiresEdgeAccount && accountStatus === 'signed-out' && ( + + {/* `ActivityBarButton` and the exit arrow's own colour, not a bespoke + button: signed out, this is one control among the bar's others and has + no reason to look different from them. `size-5` is the interface icons' + default, and `#B4D0FE` is what `ExitButton` right above it uses. + + An icon rather than an avatar with nobody in it — that falls back to + `?`, which reads as something being wrong rather than as a way in. */} + setSignInDialogOpen(true)}> + + + + )}
{/* Gated on `signed-out` rather than `!user`, so a slow /auth/me never - flashes a sign-in prompt at someone who is already signed in. */} + flashes a sign-in prompt at someone who is already signed in. + + `open` follows `requiresEdgeAccount`, not the signed-out state alone. Where + an account is required — the web editor, which can only reach a project + through Edge's API — a visitor who is not signed in has nothing to look at, + so the dialog opens by itself exactly as it always has. Where it is not — + the desktop editor, which opens local projects from disk and works offline — + the same dialog is reached from the same slot by asking for it. Forcing it + there would block an editor that needs nothing from Edge. */} {caps.hasEdgeAccount && edgeAccount && accountStatus === 'signed-out' && ( { diff --git a/src/frontend/hooks/__tests__/use-device-connect.test.ts b/src/frontend/hooks/__tests__/use-device-connect.test.ts index 0d6d51c49..a4824e30b 100644 --- a/src/frontend/hooks/__tests__/use-device-connect.test.ts +++ b/src/frontend/hooks/__tests__/use-device-connect.test.ts @@ -19,11 +19,12 @@ const currentStatus = (): string => (mockState.deviceConnection as { status: str const mockStartLicenseCheck = jest.fn() const mockSetLicenseReport = jest.fn() const mockClearDeviceLicense = jest.fn() +const mockSetAwaitingPurchase = jest.fn() const mockState: Record = { deviceDefinitions: { configuration: { deviceBoard: 'Test Board', communicationPort: 'COM5', vendorScreenData: {} } }, deviceConnection: { status: 'disconnected', port: null }, - deviceLicense: { phase: 'idle', report: null }, + deviceLicense: { phase: 'idle', report: null, awaitingPurchaseUntil: null }, runtimeConnection: { ipAddress: '192.168.0.128', jwtToken: 'jwt-tok' }, modalActions: { openModal: mockOpenModal }, consoleActions: { addLog: mockAddLog }, @@ -32,6 +33,7 @@ const mockState: Record = { startDeviceLicenseCheck: mockStartLicenseCheck, setDeviceLicenseReport: mockSetLicenseReport, clearDeviceLicense: mockClearDeviceLicense, + setAwaitingPurchase: mockSetAwaitingPurchase, }, } diff --git a/src/frontend/hooks/use-edge-account.ts b/src/frontend/hooks/use-edge-account.ts index 27e7c1a3f..f877c384e 100644 --- a/src/frontend/hooks/use-edge-account.ts +++ b/src/frontend/hooks/use-edge-account.ts @@ -191,6 +191,33 @@ export function useEdgeAccount(enabled: boolean, account?: EdgeAccountPort): Use }) }, [active, account]) + /** + * Adopt a sign-in that happened somewhere else in the app. + * + * Every consumer of this hook holds its own state, and only the one whose dialog + * performed the sign-in calls `refresh`. So a second consumer — the project card + * menu deciding whether to offer "Upload to Cloud" — kept showing the signed-out + * answer until it happened to remount, which is why closing the app or opening a + * project and coming back appeared to fix it. + * + * The session already broadcasts this: a read that finds a user calls + * `markRestored()`, and that fires here for every listener. Cheaper and more + * honest than polling, and it is the same signal the cloud project list uses. + * + * `onExpired` below covers the other direction. Not scoped to a status: a + * consumer that is already `signed-in` still needs to re-read, because the + * account that signed in may not be the one it was showing. + */ + useEffect(() => { + if (!active || !account) { + return + } + + return account.session.onRestored(() => { + void refresh() + }) + }, [active, account, refresh]) + /** * Re-check when this tab regains focus while signed out. * diff --git a/src/frontend/screens/start-screen.tsx b/src/frontend/screens/start-screen.tsx index 977a9032f..5c1666aa3 100644 --- a/src/frontend/screens/start-screen.tsx +++ b/src/frontend/screens/start-screen.tsx @@ -5,6 +5,8 @@ import { FolderIcon } from '../assets/icons/interface/Folder' import { PlusIcon } from '../assets/icons/interface/Plus' import { StickArrowIcon } from '../assets/icons/interface/StickArrow' import { VideoIcon } from '../assets/icons/interface/Video' +import { StartAccountSection } from '../components/_features/[start]/account' +import { StartCloudProjects } from '../components/_features/[start]/cloud-projects' import { MenuDivider, MenuItem, MenuRoot, MenuSection } from '../components/_features/[start]/menu' import DisplayRecentProjects from '../components/_organisms/display-recent-projects' import { ProjectFilterBar } from '../components/_organisms/project-filter-bar' @@ -14,6 +16,12 @@ import { useOpenPLCStore } from '../store' const StartScreen = () => { const [searchFilterValue, setSearchFilterProps] = useState('') + /** + * Bumped whenever something on this screen changes what is on the Edge account, so the + * cloud list re-reads. The two sections are siblings that know nothing about each + * other; this screen is the only place that knows both are here. + */ + const [cloudRevision, setCloudRevision] = useState(0) const capabilities = useCapabilities() useSystem() const projectPort = useProject() @@ -108,6 +116,9 @@ const StartScreen = () => { Tutorials + {/* Above the divider, with the things you DO here. Below it is only + leaving, and the account is not on the way out. */} + @@ -119,7 +130,15 @@ const StartScreen = () => { - + {/* Above the local list, and hidden entirely when there is nothing to show — so + an editor with no account, or nobody signed in, looks exactly as it did. The + filter box covers both sections, because a person searching for a project + does not care which side of the line it is on. */} + + setCloudRevision((current) => current + 1)} + /> ) diff --git a/src/frontend/screens/workspace-screen.tsx b/src/frontend/screens/workspace-screen.tsx index aa121ed31..4eac1cd2a 100644 --- a/src/frontend/screens/workspace-screen.tsx +++ b/src/frontend/screens/workspace-screen.tsx @@ -3,7 +3,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { ImperativePanelHandle } from 'react-resizable-panels' import { useShallow } from 'zustand/react/shallow' -import { projectCapabilities } from '../../middleware/shared/ports/types' +import { isRemoteProjectPath, projectCapabilities } from '../../middleware/shared/ports/types' import { useCapabilities, useChatPanel, @@ -15,6 +15,8 @@ import { import { ExitIcon } from '../assets/icons/interface/Exit' import { ClearConsoleButton } from '../components/_atoms/buttons/console/clear-console' import { BranchStatusBar } from '../components/_features/[workspace]/branches' +import { BranchMergeView } from '../components/_features/[workspace]/branches/branch-merge-view' +import { CommitHistoryView } from '../components/_features/[workspace]/commit-history' import { DataTypeEditor } from '../components/_features/[workspace]/data-type' import { DeviceEditor } from '../components/_features/[workspace]/editor/device' import { EtherCATDeviceEditor, EtherCATEditor } from '../components/_features/[workspace]/editor/device/ethercat' @@ -57,6 +59,7 @@ import { useDeviceConnectionMonitor } from '../hooks/use-device-connection-monit import { useDevicePlcState } from '../hooks/use-device-plc-state' import { useRuntimePolling } from '../hooks/use-runtime-polling' import { forceDebugVariable, releaseDebugVariable } from '../services/debug-force-variable' +import { buildAllProjectFileContentsPure } from '../services/save-actions' import { useOpenPLCStore } from '../store' import { cn } from '../utils/cn' import { buildGlobalCompositeKey, GLOBAL_CONFIG_NAME } from '../utils/debug-variable-finder' @@ -131,13 +134,18 @@ const WorkspaceScreen = () => { ) // Version control state - const { activePanel, pendingChangesCount } = useOpenPLCStore( + const { activePanel, pendingChangesCount, historyView, mergeView, rawLoadedContent } = useOpenPLCStore( useShallow((s) => ({ activePanel: s.versionControl.activePanel, pendingChangesCount: s.versionControl.pendingChangesCount, + historyView: s.versionControl.historyView, + mergeView: s.versionControl.mergeView, + rawLoadedContent: s.versionControl.rawLoadedContent, })), ) - const { setActivePanel } = useOpenPLCStore(useCallback((s) => s.versionControlActions, [])) + const { setActivePanel, closeHistoryView, closeMergeView } = useOpenPLCStore( + useCallback((s) => s.versionControlActions, []), + ) const sharedWorkspaceActions = useOpenPLCStore(useCallback((s) => s.sharedWorkspaceActions, [])) const isDebuggerVisible = useIsDebuggerVisible() @@ -145,12 +153,62 @@ const WorkspaceScreen = () => { const debugNonBoolValues = useDebugNonBoolValuesMap() const debugForcedVariables = useDebugForcedVariablesMap() - // Version control is an intersection: the host must support it - // (web edition has its own VC adapter; desktop has git) AND the - // project type must allow it. Library projects ship without VC - // for now — git-on-library is plausible but out of scope and - // would re-introduce the same UI churn we just removed. - const hasVersionControl = capabilities.hasVersionControl && projectCaps.hasVersionControl + // Version control is an intersection of three things: the host must support + // it, the project type must allow it, and the project must actually live on + // the server. Library projects ship without VC for now — git-on-library is + // plausible but out of scope and would re-introduce the same UI churn we + // just removed. + // + // The third term is what keeps the desktop honest. The repository sits + // beside the project on Edge, so a project opened from disk has no history + // to show; offering branches for it would be offering a button that cannot + // work. On the web every project is an Edge project, so the term is always + // true there and nothing changes. + const hasVersionControl = + capabilities.hasVersionControl && projectCaps.hasVersionControl && isRemoteProjectPath(projectPath) + + /** + * Establish the version-control sync point for the project that just loaded. + * + * `pickContentForSave` needs two things per path: the serialization taken at load time, + * and the bytes as they arrived. Given both, a file whose fresh serialization still equals + * the load-time one is echoed back unchanged; given neither, every file is re-serialised on + * every save. That is what made a save rewrite an entire project in the editor's own + * formatting — 62KB to 147KB on a real one — and report every file as modified against + * HEAD. + * + * KEYED ON THE RAW MAP'S IDENTITY, not on `projectPath`. A branch switch, restore, discard + * or stash reloads the same project: the path does not change, but the loaded bytes do, and + * the sync point has to follow them. The store replaces the map on every load, so its + * identity is the signal that one happened. + * + * SKIPPED WHERE SOMETHING ALREADY DID IT. The web establishes this in its router page + * before the workspace mounts, so `loadedSerialized` is already populated by the time this + * runs there and it leaves it alone. The condition is about state, not about which product + * this is — a platform that starts doing it earlier gets the same treatment for free. + */ + useEffect(() => { + if (!projectPath) { + return + } + + const state = useOpenPLCStore.getState() + + if (Object.keys(state.versionControl.loadedSerialized).length > 0) { + return + } + + // Serialised from the state that was just loaded, which is what makes it a baseline: + // anything differing from it later is a real edit. + const baselineContent = buildAllProjectFileContentsPure() + + state.versionControlActions.initBaseline({ + initialPending: [], + baselineContent, + rawLoadedContent: state.versionControl.rawLoadedContent, + loadedSerialized: baselineContent, + }) + }, [projectPath, rawLoadedContent]) // Start global runtime polling for status and logs useRuntimePolling() @@ -296,24 +354,40 @@ const WorkspaceScreen = () => { ) const [isVariablesPanelCollapsed, setIsVariablesPanelCollapsed] = useState(false) + /** + * Re-read the open project from its source. + * + * Both a branch switch and a commit restore rewrite the working tree on the server, + * which leaves everything in memory stale. `whenStale` is the caller's own wording for + * the half-done case — the operation succeeded and the reload did not, and telling + * someone their branch switch failed when it did not is its own bug. + */ + const reloadOpenProject = useCallback( + async (whenStale: string): Promise => { + if (!projectPath) return false + const result = await project.openProjectByPath(projectPath) + + if (result.success && result.data) { + sharedWorkspaceActions.handleOpenProjectResponse(result.data) + return true + } + + toast({ title: 'Failed to reload project', description: whenStale, variant: 'fail' }) + return false + }, + [projectPath, project, sharedWorkspaceActions], + ) + const handleBranchSwitch = useCallback( async (branchName: string) => { if (!projectPath) return try { - const result = await project.openProjectByPath(projectPath) - if (result.success && result.data) { - sharedWorkspaceActions.handleOpenProjectResponse(result.data) + if (await reloadOpenProject('The branch was switched but the project could not be reloaded.')) { toast({ title: 'Branch switched', description: `Now on branch: ${branchName}`, variant: 'default', }) - } else { - toast({ - title: 'Failed to reload project', - description: 'The branch was switched but the project could not be reloaded.', - variant: 'fail', - }) } } catch (error) { console.error('[WorkspaceScreen] Failed to switch branch:', error) @@ -324,7 +398,7 @@ const WorkspaceScreen = () => { }) } }, - [projectPath, project, sharedWorkspaceActions], + [projectPath, reloadOpenProject], ) type PanelMethods = { @@ -907,6 +981,53 @@ const WorkspaceScreen = () => { {hasVersionControl && projectPath && ( )} + + {/* The commit's full-file view, laid over the workspace. + * + * Set only by a platform whose navigation adapter has nowhere else to put it — the + * desktop, which has no router. The web's adapter navigates to `/history` in a new + * tab instead, leaving this null and this branch dead there, so the two products + * render the same screen from the same component without the web losing its tab. + * + * `inset-0` over the whole workspace rather than a modal: it is a screen, not a + * dialog, and the graphical diff of a ladder program needs the room. */} + {hasVersionControl && projectPath && historyView && ( +
+ { + closeHistoryView() + void reloadOpenProject('The project was restored but could not be reloaded.') + }} + /> +
+ )} + + {/* The branch merge screen, laid over the workspace. + * + * Same arrangement as the commit view above, and set the same way: only a platform + * whose navigation adapter has nowhere else to put it — the desktop, with no router. + * The web navigates to `/merge` instead and leaves this null. + * + * Closing on a completed merge reloads the project, because the merge moved the + * branch on the server and everything in memory is now behind it. */} + {hasVersionControl && projectPath && mergeView && ( +
+ { + closeMergeView() + void reloadOpenProject('The merge completed but the project could not be reloaded.') + }} + /> +
+ )}
) } diff --git a/src/frontend/store/__tests__/device-types.test.ts b/src/frontend/store/__tests__/device-types.test.ts index 8aa250908..1b6d4bf3f 100644 --- a/src/frontend/store/__tests__/device-types.test.ts +++ b/src/frontend/store/__tests__/device-types.test.ts @@ -193,7 +193,7 @@ describe('Device slice types', () => { includeEthercatStatsInPolling: false, }, deviceConnection: { status: 'disconnected', port: null, transport: null, debugTransport: null }, - deviceLicense: { phase: 'idle', report: null }, + deviceLicense: { phase: 'idle', report: null, awaitingPurchaseUntil: null }, } expect(state.deviceAvailableOptions).toBeDefined() expect(state.deviceDefinitions).toBeDefined() diff --git a/src/frontend/store/__tests__/version-control-slice.test.ts b/src/frontend/store/__tests__/version-control-slice.test.ts index 93d2a87a5..402edfcf4 100644 --- a/src/frontend/store/__tests__/version-control-slice.test.ts +++ b/src/frontend/store/__tests__/version-control-slice.test.ts @@ -205,4 +205,61 @@ describe('createVersionControlSlice', () => { expect(vc().headContent).toBeNull() expect(vc().pendingChangesCount).toBe(0) }) + + /** + * The two overlay screens the desktop reaches instead of routing. There is no + * router in the editor, so the navigation adapter turns `/history` and + * `/merge` into this state and the workspace lays the screen over itself. + * Opening has to copy the descriptor rather than hold the caller\'s object: + * the caller is a click handler whose argument it is free to mutate + * afterwards, and a held reference would let it rewrite what is on screen. + */ + describe('the overlay screens', () => { + it('opens the history view on a commit, with or without a file', () => { + actions().openHistoryView({ commitHash: 'abc1234' }) + expect(vc().historyView).toEqual({ commitHash: 'abc1234' }) + + actions().openHistoryView({ commitHash: 'def5678', file: 'pous/functions/Scale.st' }) + expect(vc().historyView).toEqual({ commitHash: 'def5678', file: 'pous/functions/Scale.st' }) + }) + + it("copies the history descriptor instead of holding the caller's object", () => { + const view = { commitHash: 'abc1234' } + actions().openHistoryView(view) + view.commitHash = 'rewritten' + expect(vc().historyView).toEqual({ commitHash: 'abc1234' }) + }) + + it('closes the history view', () => { + actions().openHistoryView({ commitHash: 'abc1234' }) + actions().closeHistoryView() + expect(vc().historyView).toBeNull() + }) + + it('opens the merge view on a source branch, with or without a target', () => { + actions().openMergeView({ sourceBranch: 'feature/pumps' }) + expect(vc().mergeView).toEqual({ sourceBranch: 'feature/pumps' }) + + actions().openMergeView({ sourceBranch: 'feature/pumps', targetBranch: 'main' }) + expect(vc().mergeView).toEqual({ sourceBranch: 'feature/pumps', targetBranch: 'main' }) + }) + + it("copies the merge descriptor instead of holding the caller's object", () => { + const view = { sourceBranch: 'feature/pumps' } + actions().openMergeView(view) + view.sourceBranch = 'rewritten' + expect(vc().mergeView).toEqual({ sourceBranch: 'feature/pumps' }) + }) + + it('closes the merge view', () => { + actions().openMergeView({ sourceBranch: 'feature/pumps' }) + actions().closeMergeView() + expect(vc().mergeView).toBeNull() + }) + + it('starts with both overlays closed', () => { + expect(vc().historyView).toBeNull() + expect(vc().mergeView).toBeNull() + }) + }) }) diff --git a/src/frontend/store/slices/shared/slice.ts b/src/frontend/store/slices/shared/slice.ts index fd15e92a9..be5be8874 100644 --- a/src/frontend/store/slices/shared/slice.ts +++ b/src/frontend/store/slices/shared/slice.ts @@ -1099,6 +1099,11 @@ const createSharedSlice: StateCreator = (s // Raw .dt files that failed to parse — stashed so saves echo // them back verbatim; always set so a reopen clears stale ones. getState().projectActions.setUnparsedDataTypeFiles(data.unparsedDataTypeFiles ?? []) + // The bytes as loaded, for the save flow to echo back for files the user does not + // touch. Always set, for the same reason as the line above: a reopen must not inherit + // the previous project's map. Empty for a reader with no separate notion of "as + // loaded" — a project on disk — which reads the same as nothing to echo. + getState().versionControlActions.setRawLoadedContent(data.rawLoadedFiles ?? {}) // Unreadable files have no PLCDataType, so no tree leaf to click. const unparsedDataTypes = (data.unparsedDataTypeFiles ?? []).flatMap((file) => { diff --git a/src/frontend/store/slices/shared/types.ts b/src/frontend/store/slices/shared/types.ts index 382f80fdf..1a1fa91d7 100644 --- a/src/frontend/store/slices/shared/types.ts +++ b/src/frontend/store/slices/shared/types.ts @@ -178,6 +178,18 @@ export type OpenProjectResponseData = { /** `datatypes/*.dt` files that failed to parse on load, preserved * raw so the save flow echoes them back verbatim. */ unparsedDataTypeFiles?: RawProjectFile[] + /** + * Every file's bytes as the reader handed them over, keyed by relative path. + * + * The same idea as `unparsedDataTypeFiles` one line up, generalised: the save flow echoes + * these back for files the user did not edit, rather than re-serialising them. Without it + * a save rewrites every file in the editor's own formatting — same meaning, different + * bytes — so the project grows and git reports the whole thing as modified. + * + * Absent for a reader with no separate notion of "as loaded", which is the filesystem: + * there the files on disk are the loaded state. + */ + rawLoadedFiles?: Record /** * Edit permission flag forwarded from `ProjectResponse.data.canEdit`. * `false` puts the workspace in read-only mode; `true` / `undefined` diff --git a/src/frontend/store/slices/version-control/slice.ts b/src/frontend/store/slices/version-control/slice.ts index 4b4590606..693948651 100644 --- a/src/frontend/store/slices/version-control/slice.ts +++ b/src/frontend/store/slices/version-control/slice.ts @@ -13,6 +13,8 @@ const initialState: VersionControlSlice['versionControl'] = { changedPaths: [], pendingChangesCount: 0, headContent: null, + historyView: null, + mergeView: null, } function dedupeByPath(entries: InitialPendingEntry[]): InitialPendingEntry[] { @@ -51,6 +53,41 @@ const createVersionControlSlice: StateCreator + setState( + produce((draft) => { + draft.versionControl.historyView = { ...view } + }), + ), + + closeHistoryView: () => + setState( + produce((draft) => { + draft.versionControl.historyView = null + }), + ), + + openMergeView: (view: { sourceBranch: string; targetBranch?: string }) => + setState( + produce((draft) => { + draft.versionControl.mergeView = { ...view } + }), + ), + + closeMergeView: () => + setState( + produce((draft) => { + draft.versionControl.mergeView = null + }), + ), + + setRawLoadedContent: (content: Record) => + setState( + produce((draft) => { + draft.versionControl.rawLoadedContent = { ...content } + }), + ), + setHeadContent: (content: Record | null) => setState( produce((draft) => { diff --git a/src/frontend/store/slices/version-control/types.ts b/src/frontend/store/slices/version-control/types.ts index 8bb0b0e97..10eb6944f 100644 --- a/src/frontend/store/slices/version-control/types.ts +++ b/src/frontend/store/slices/version-control/types.ts @@ -65,6 +65,21 @@ export type VersionControlState = { * show a diff for changes made before the current session. */ headContent: Record | null + /** + * The commit whose full-file view is open, or `null` for none. + * + * Only the desktop uses this. On the web, "View all files" opens `/history` in a new + * tab so the workspace stays where it was, and the route holds the same two values in + * its search params. The desktop has no router and only one window worth using, so its + * navigation adapter puts them here instead and the workspace screen lays the view + * over itself. Same screen either way — see `CommitHistoryView`. + */ + historyView: { commitHash: string; file?: string } | null + /** + * The branch merge screen that is open, or `null`. Desktop only, for the same reason as + * `historyView`: the web reaches `/merge` through its router and leaves this null. + */ + mergeView: { sourceBranch: string; targetBranch?: string } | null } } @@ -73,6 +88,20 @@ export type SavedFileRecord = { path: string; content: string } export type VersionControlActions = { setActivePanel: (panel: SidePanel) => void setSelectedCommitHash: (hash: string | null) => void + /** Open the full-file view for a commit. Desktop only — see `historyView`. */ + openHistoryView: (view: { commitHash: string; file?: string }) => void + /** Close it, returning to the workspace underneath. */ + closeHistoryView: () => void + /** Open the merge screen for a branch. Desktop only — see `mergeView`. */ + openMergeView: (view: { sourceBranch: string; targetBranch?: string }) => void + /** Close it, returning to the workspace underneath. */ + closeMergeView: () => void + /** + * Keep the bytes a reader handed over, so the save flow can echo unedited files back + * unchanged. Set on every project load — including a reopen, so a stale map from the + * previous project is never left behind. + */ + setRawLoadedContent: (content: Record) => void /** Set (or clear, with `null`) the lazily-fetched HEAD snapshot used as the * "original" side of source-control diffs. */ setHeadContent: (content: Record | null) => void diff --git a/src/frontend/utils/__tests__/ignore-monaco-cancellations.test.ts b/src/frontend/utils/__tests__/ignore-monaco-cancellations.test.ts new file mode 100644 index 000000000..ac46bcbc9 --- /dev/null +++ b/src/frontend/utils/__tests__/ignore-monaco-cancellations.test.ts @@ -0,0 +1,65 @@ +import { installMonacoCancellationGuard } from '@root/frontend/utils/ignore-monaco-cancellations' + +/** + * `PromiseRejectionEvent` is not implemented in jsdom, and the guard reads only + * `reason` and calls `preventDefault`. A plain `Event` carrying those two is + * enough to exercise it, and keeps the test off a DOM API the runner lacks. + */ +const rejectionEvent = (reason: unknown) => { + const event = new Event('unhandledrejection', { cancelable: true }) + Object.defineProperty(event, 'reason', { value: reason }) + return event +} + +const cancellation = () => { + const error = new Error('Canceled') + error.name = 'Canceled' + return error +} + +describe('installMonacoCancellationGuard', () => { + beforeAll(() => { + installMonacoCancellationGuard() + }) + + it("swallows Monaco's cancellation rejections", () => { + const event = rejectionEvent(cancellation()) + window.dispatchEvent(event) + expect(event.defaultPrevented).toBe(true) + }) + + it('lets every other rejection through', () => { + const event = rejectionEvent(new Error('Request failed with status code 500')) + window.dispatchEvent(event) + expect(event.defaultPrevented).toBe(false) + }) + + it('lets through an error that only reads like a cancellation', () => { + // The message alone is not the signal: a backend that answers "Canceled" + // must still reach whatever reports errors to the user. + const event = rejectionEvent(new Error('Canceled')) + window.dispatchEvent(event) + expect(event.defaultPrevented).toBe(false) + }) + + it('lets through a non-Error rejection', () => { + const event = rejectionEvent({ name: 'Canceled' }) + window.dispatchEvent(event) + expect(event.defaultPrevented).toBe(false) + }) + + it('registers one listener however many times it is installed', () => { + installMonacoCancellationGuard() + installMonacoCancellationGuard() + // The same function reference goes in every time, so the browser + // de-duplicates: repeat installs never double-handle a rejection. + let seen = 0 + const count = () => { + seen += 1 + } + window.addEventListener('unhandledrejection', count) + window.dispatchEvent(rejectionEvent(cancellation())) + window.removeEventListener('unhandledrejection', count) + expect(seen).toBe(1) + }) +}) diff --git a/src/frontend/utils/ignore-monaco-cancellations.ts b/src/frontend/utils/ignore-monaco-cancellations.ts new file mode 100644 index 000000000..6628f1e58 --- /dev/null +++ b/src/frontend/utils/ignore-monaco-cancellations.ts @@ -0,0 +1,45 @@ +/** + * Monaco cancels in-flight work by *rejecting* with an error it names + * `Canceled`. Every debounced editor contribution — the word-occurrences + * highlighter, folding, link detection — owns a `Delayer` whose pending + * promise is rejected the moment the editor is disposed, and nothing inside + * Monaco attaches a `catch` to it. So an editor torn down while any Delayer is + * still armed leaves an unhandled rejection behind: + * + * Canceled: Canceled + * at Delayer.cancel + * at WordHighlighter.dispose + * at DisposableMap.dispose + * + * It is not a failure — the work was cancelled on purpose, which is exactly + * what disposal means. But it reaches `window` as an unhandled rejection, and + * in a dev build the bundler's error overlay covers the whole app with it, + * which is how this was found: stashing from the source-control panel reloads + * the project, that unmounts the open POU editor, and the overlay then blocked + * every click until it was dismissed. + * + * The one existing workaround for the same rejection turns the highlighter off + * outright (`occurrencesHighlight: 'off'` in the library-manifest editor). That + * is fine for a JSON manifest with nothing worth highlighting, and wrong for + * the POU editors, where highlighting a variable's other occurrences is the + * point. Swallowing just the cancellation keeps the feature. + * + * Deliberately narrow: only a rejection whose reason carries Monaco's own + * `Canceled` name is suppressed. Any other rejection still surfaces. + */ + +const isMonacoCancellation = (reason: unknown): boolean => reason instanceof Error && reason.name === 'Canceled' + +/** + * Installs the listener on `window`. Safe to call more than once: the same + * function reference is passed to `addEventListener`, so a repeat call is a + * no-op rather than a second handler. + */ +export const installMonacoCancellationGuard = (): void => { + window.addEventListener('unhandledrejection', handleUnhandledRejection) +} + +function handleUnhandledRejection(event: PromiseRejectionEvent): void { + if (!isMonacoCancellation(event.reason)) return + event.preventDefault() +} diff --git a/src/main.tsx b/src/main.tsx index 165d9aef8..7ebcb500b 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -3,6 +3,9 @@ import './frontend/locales/i18n' import { createRoot } from 'react-dom/client' import App from './App' +import { installMonacoCancellationGuard } from './frontend/utils/ignore-monaco-cancellations' + +installMonacoCancellationGuard() const container = document.getElementById('root') as HTMLElement const root = createRoot(container) diff --git a/src/main/main.ts b/src/main/main.ts index 5ae531b3e..085507cd5 100644 --- a/src/main/main.ts +++ b/src/main/main.ts @@ -18,6 +18,8 @@ import { ensureCliShimInstalled, shimStatePath } from '../backend/editor/cli-shi import { CompilerModule } from '../backend/editor/compiler' // TODO: Refactor this type declaration import { MainIpcModuleConstructor } from '../backend/editor/contracts/types/modules/ipc/main' +import { adoptProviderTokens } from '../backend/editor/edge-account/edge-account-service' +import { edgeOAuthProviderFromUrl, runOAuthFlow } from '../backend/editor/edge-account/oauth-window' import { HardwareModule } from '../backend/editor/hardware' import { logger, PouService, ProjectService, UserService } from '../backend/editor/services' import { resolveHtmlPath } from '../backend/editor/utils' @@ -266,6 +268,48 @@ const createMainWindow = async () => { // Open urls in the user's browser mainWindow.webContents.setWindowOpenHandler((edata) => { + /** + * Provider sign-in is the one link that must NOT go to the system browser. + * + * The shared sign-in dialog renders each provider as a `target='_blank'` link, which + * is exactly right on the web: the new tab shares Edge's cookie jar, so the session + * it establishes is the session the editor is already using. A desktop app shares + * nothing with the system browser — the tokens would land in a jar this process + * cannot read, and the user would come back to an editor that still says they are + * signed out. Which is precisely what happened before this existed: the click just + * opened Edge in a browser and nothing came back. + * + * So the intent the link expresses is honoured by a different mechanism: a window + * this process owns, whose cookies it can read. The shared component says WHERE to + * go; the platform decides HOW. + * + * The window closing and focus returning here is what tells the renderer to + * re-check — the account hook already re-reads on focus while signed out, which is + * how the web build closes its own provider round-trip too. + */ + const provider = edgeOAuthProviderFromUrl(edata.url) + + if (provider) { + void runOAuthFlow(provider) + .then((outcome) => { + if (outcome.status !== 'tokens') { + // Cancelled, declined or timed out. Nothing to adopt and nothing to report: + // the renderer re-checks on focus and finds nobody signed in, which is true. + return undefined + } + + return adoptProviderTokens({ + accessToken: outcome.accessToken, + refreshToken: outcome.refreshToken, + }) + }) + .catch((error: unknown) => { + log.error(`[edge-account] provider sign-in failed: ${getErrorMessage(error)}`) + }) + + return { action: 'deny' } + } + void shell.openExternal(edata.url) return { action: 'deny' } }) diff --git a/src/main/modules/ipc/main.ts b/src/main/modules/ipc/main.ts index 105fc8805..1ed8d154a 100644 --- a/src/main/modules/ipc/main.ts +++ b/src/main/modules/ipc/main.ts @@ -1,3 +1,37 @@ +import { + fetchPlanCaption as fetchEdgePlanCaption, + fetchUser as fetchEdgeUser, + isEncryptionAvailable, + signIn as signInToEdge, + signOut as signOutOfEdge, +} from '@root/backend/editor/edge-account/edge-account-service' +import { listCloudFolders, uploadProjectToCloud } from '@root/backend/editor/edge-project-upload' +import { + listRecentCloudProjects, + readCloudProject, + saveCloudFile, + saveCloudProject, +} from '@root/backend/editor/edge-projects' +import { + applyStash, + createBranch, + createCommit, + createStash, + deleteBranch, + discardChanges, + dropStash, + getBranchDiffWithBase, + getChanges, + getCommitFiles, + listBranches, + listCommits, + listStashes, + mergeBranches, + popStash, + previewSwitchCarry, + restoreCommit, + switchBranch, +} from '@root/backend/editor/edge-version-control' import { ESIService } from '@root/backend/editor/ethercat' import { createDesktopCatalogTransport } from '@root/backend/editor/library-manager/desktop-catalog-transport' import type { @@ -15,6 +49,7 @@ import { getErrorMessage } from '@root/frontend/utils/get-error-message' import type { CompileProgramIpcArgs } from '@root/middleware/adapters/editor/compile-program-flow' import { RuntimeLogEntry } from '@root/middleware/shared/ports' import type { DeviceLicenseReport, DeviceLicenseRequest } from '@root/middleware/shared/ports/device-port' +import type { EdgeSignInOutcome, EdgeUserRead } from '@root/middleware/shared/ports/edge-account-port' import type { EtherCATRuntimeStatusResponse, EtherCATScanRequest, @@ -26,6 +61,13 @@ import type { EtherCATValidateResponse, NetworkInterface, } from '@root/middleware/shared/ports/ethercat-types' +import type { + CloudFoldersResult, + CloudProjectsResult, + RawProjectFiles, + UploadProjectResult, + WriteProjectFiles, +} from '@root/middleware/shared/ports/project-port' import type { ListPublicLibrariesArgs, ListPublicLibrariesResponse, @@ -33,6 +75,7 @@ import type { } from '@root/middleware/shared/ports/public-catalog-types' import type { RuntimeUser, RuntimeUserRole, UpdateUserParams } from '@root/middleware/shared/ports/runtime-port' import type { DebugConnectionConfig } from '@root/middleware/shared/ports/types' +import type { VersionControlResult } from '@root/middleware/shared/ports/version-control-port' import { CreatePouFileProps } from '@root/types/IPC/pou-service' import { CreateProjectFileProps } from '@root/types/IPC/project-service' import { randomUUID } from 'crypto' @@ -588,6 +631,41 @@ class MainProcessBridge implements MainIpcModule { this.registerHandle('libraries:install-from-file', this.handleLibrariesInstallFromFile) this.registerHandle('libraries:uninstall', this.handleLibrariesUninstall) this.registerHandle('catalog:list', this.handleCatalogList) + // ----- Edge account (optional sign-in) ----- + // All of it runs in the main process: the renderer is not on Edge's origin, so + // it can neither inherit a shared-domain cookie nor make the request itself. + this.registerHandle('edge-account:fetch-user', this.handleEdgeFetchUser) + this.registerHandle('edge-account:fetch-plan-caption', this.handleEdgeFetchPlanCaption) + this.registerHandle('edge-account:sign-in', this.handleEdgeSignIn) + this.registerHandle('edge-account:sign-out', this.handleEdgeSignOut) + this.registerHandle('edge-account:is-session-persistent', this.handleEdgeIsSessionPersistent) + // ----- Edge projects (the cloud half of the start screen) ----- + this.registerHandle('edge-projects:list-recent', this.handleEdgeProjectsListRecent) + this.registerHandle('edge-projects:read', this.handleEdgeProjectsRead) + this.registerHandle('edge-projects:save-project', this.handleEdgeProjectsSaveProject) + this.registerHandle('edge-projects:save-file', this.handleEdgeProjectsSaveFile) + // ----- Publishing a local project to Edge ----- + this.registerHandle('edge-upload:list-folders', this.handleEdgeUploadListFolders) + this.registerHandle('edge-upload:project', this.handleEdgeUploadProject) + // ----- Edge version control (branches, commits, changes, stashes) ----- + this.registerHandle('edge-vc:list-branches', this.handleEdgeVcListBranches) + this.registerHandle('edge-vc:create-branch', this.handleEdgeVcCreateBranch) + this.registerHandle('edge-vc:delete-branch', this.handleEdgeVcDeleteBranch) + this.registerHandle('edge-vc:switch-branch', this.handleEdgeVcSwitchBranch) + this.registerHandle('edge-vc:preview-switch-carry', this.handleEdgeVcPreviewSwitchCarry) + this.registerHandle('edge-vc:list-commits', this.handleEdgeVcListCommits) + this.registerHandle('edge-vc:create-commit', this.handleEdgeVcCreateCommit) + this.registerHandle('edge-vc:get-commit-files', this.handleEdgeVcGetCommitFiles) + this.registerHandle('edge-vc:restore-commit', this.handleEdgeVcRestoreCommit) + this.registerHandle('edge-vc:get-changes', this.handleEdgeVcGetChanges) + this.registerHandle('edge-vc:discard-changes', this.handleEdgeVcDiscardChanges) + this.registerHandle('edge-vc:list-stashes', this.handleEdgeVcListStashes) + this.registerHandle('edge-vc:create-stash', this.handleEdgeVcCreateStash) + this.registerHandle('edge-vc:apply-stash', this.handleEdgeVcApplyStash) + this.registerHandle('edge-vc:pop-stash', this.handleEdgeVcPopStash) + this.registerHandle('edge-vc:drop-stash', this.handleEdgeVcDropStash) + this.registerHandle('edge-vc:branch-diff-with-base', this.handleEdgeVcBranchDiffWithBase) + this.registerHandle('edge-vc:merge-branches', this.handleEdgeVcMergeBranches) this.registerHandle('catalog:install-many', this.handleCatalogInstallMany) this.registerHandle('app:store-retrieve-recent', this.handleStoreRetrieveRecent) this.registerHandle('project:remove-from-recent', this.handleRemoveProjectFromRecent) @@ -988,6 +1066,406 @@ class MainProcessBridge implements MainIpcModule { * rather than thrown across the IPC boundary so the modal can * surface the failure without trying to read a rejected promise. */ + // ===================== EDGE ACCOUNT ===================== + // Signing in is OPTIONAL throughout. Every handler resolves to a value the renderer + // can render, none of them is an error the editor must recover from, and someone + // working offline on a local project never triggers any of it. + + handleEdgeFetchUser = (_event: IpcMainInvokeEvent): Promise => fetchEdgeUser() + + handleEdgeFetchPlanCaption = (_event: IpcMainInvokeEvent): Promise => fetchEdgePlanCaption() + + handleEdgeSignIn = ( + _event: IpcMainInvokeEvent, + credentials: { email: string; password: string }, + ): Promise => { + // Validated rather than trusted: this crosses IPC, and a malformed payload must + // come back as a failed sign-in instead of throwing inside the handler and + // rejecting the invoke with a stack trace the UI cannot render. + if (typeof credentials?.email !== 'string' || typeof credentials?.password !== 'string') { + return Promise.resolve({ status: 'failed' }) + } + + return signInToEdge(credentials.email, credentials.password) + } + + handleEdgeSignOut = (_event: IpcMainInvokeEvent): Promise => signOutOfEdge() + + /** Whether a session on this machine survives a restart — see `session-store`. */ + handleEdgeIsSessionPersistent = (_event: IpcMainInvokeEvent): Promise => + Promise.resolve(isEncryptionAvailable()) + + // ===================== EDGE PROJECTS ===================== + // The cloud round trip. Reads and writes go through the same session the account + // handlers use, so a revoked token is renewed once rather than per call site. + + handleEdgeProjectsListRecent = (_event: IpcMainInvokeEvent, limit: unknown): Promise => { + // Clamped rather than trusted: this crosses IPC, and an absurd limit would be + // forwarded straight into the API's own bounds check as a 400. + const requested = typeof limit === 'number' && Number.isInteger(limit) ? limit : 5 + + return listRecentCloudProjects(Math.min(Math.max(requested, 1), 50)) + } + + handleEdgeProjectsRead = (_event: IpcMainInvokeEvent, projectId: unknown): Promise => { + if (typeof projectId !== 'string' || projectId.length === 0) { + return Promise.resolve({ + success: false, + error: { title: 'Failed to open project', description: 'No project id was given.' }, + }) + } + + return readCloudProject(projectId) + } + + handleEdgeProjectsSaveProject = ( + _event: IpcMainInvokeEvent, + files: WriteProjectFiles, + ): Promise<{ success: boolean; error?: string }> => { + if (typeof files?.projectPath !== 'string' || files.projectPath.length === 0) { + return Promise.resolve({ success: false, error: 'No project id was given.' }) + } + + return saveCloudProject(files) + } + + handleEdgeProjectsSaveFile = ( + _event: IpcMainInvokeEvent, + filePath: unknown, + content: unknown, + ): Promise<{ success: boolean; error?: string }> => { + if (typeof filePath !== 'string' || filePath.length === 0) { + return Promise.resolve({ success: false, error: 'No file path was given.' }) + } + + return saveCloudFile(filePath, content) + } + + // ------------------------------------------------------------------------- + // Edge version control + // ------------------------------------------------------------------------- + // + // Every handler validates before it builds a URL. A non-string project id would + // otherwise be interpolated as `undefined` and ask the API about a project by that + // name, and a missing branch name would POST an empty one — both come back as a + // confusing 400 rather than as the local mistake they are. + // + // Optional arguments are normalised rather than forwarded: `undefined` arriving over + // IPC as `null` is the difference between "commit everything" and "commit no files". + + /** Non-empty string, or nothing. */ + private static vcString(value: unknown): string | undefined { + return typeof value === 'string' && value.length > 0 ? value : undefined + } + + /** Narrows an IPC argument to something indexable, without asserting. */ + private static vcRecord(value: unknown): Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) + ? Object.fromEntries(Object.entries(value)) + : {} + } + + /** An array of non-empty strings, or nothing — never a partially valid list. */ + private static vcStringArray(value: unknown): string[] | undefined { + if (!Array.isArray(value)) { + return undefined + } + + const strings = value.filter((entry): entry is string => typeof entry === 'string' && entry.length > 0) + + return strings.length === value.length ? strings : undefined + } + + private static readonly VC_BAD_REQUEST = { + ok: false, + failure: { kind: 'http', status: 400, message: 'The editor made an invalid version-control request.' }, + } as const satisfies VersionControlResult + + handleEdgeUploadListFolders = (): Promise => listCloudFolders() + + /** + * Validates before it touches the filesystem. `projectPath` becomes a directory walk and + * `parentFolderId` becomes a form field the server trusts, so neither may arrive as + * anything but a non-empty string, and visibility is narrowed to the two the API accepts + * rather than forwarded — a typo would otherwise publish a project as public. + */ + handleEdgeUploadProject = (_event: IpcMainInvokeEvent, params: unknown): Promise => { + const source = MainProcessBridge.vcRecord(params) + const projectPath = MainProcessBridge.vcString(source.projectPath) + const parentFolderId = MainProcessBridge.vcString(source.parentFolderId) + + if (!projectPath || !parentFolderId) { + return Promise.resolve({ + status: 'failed', + failure: { reason: 'unreadable', message: 'The editor made an invalid upload request.' }, + }) + } + + return uploadProjectToCloud({ + projectPath, + parentFolderId, + projectName: MainProcessBridge.vcString(source.projectName), + // Anything but an explicit 'public' stays private. Guessing in the other direction + // would publish someone's work to the world on a malformed value. + visibility: source.visibility === 'public' ? 'public' : 'private', + }) + } + + handleEdgeVcBranchDiffWithBase = ( + _event: IpcMainInvokeEvent, + projectId: unknown, + source: unknown, + target: unknown, + ): Promise> => { + const id = MainProcessBridge.vcString(projectId) + const from = MainProcessBridge.vcString(source) + const to = MainProcessBridge.vcString(target) + + return id && from && to ? getBranchDiffWithBase(id, from, to) : Promise.resolve(MainProcessBridge.VC_BAD_REQUEST) + } + + handleEdgeVcMergeBranches = (_event: IpcMainInvokeEvent, params: unknown): Promise> => { + const source = MainProcessBridge.vcRecord(params) + const projectId = MainProcessBridge.vcString(source.projectId) + const sourceBranch = MainProcessBridge.vcString(source.sourceBranch) + const targetBranch = MainProcessBridge.vcString(source.targetBranch) + + if (!projectId || !sourceBranch || !targetBranch) { + return Promise.resolve(MainProcessBridge.VC_BAD_REQUEST) + } + + // Resolutions decide file CONTENT, so a malformed map is refused rather than partially + // forwarded: dropping an entry would merge with the wrong side of a conflict silently. + let resolutions: Record | undefined + + if (source.resolutions !== undefined) { + const record = MainProcessBridge.vcRecord(source.resolutions) + const entries = Object.entries(record) + + if (!entries.every(([key, value]) => key.length > 0 && typeof value === 'string')) { + return Promise.resolve(MainProcessBridge.VC_BAD_REQUEST) + } + + resolutions = Object.fromEntries(entries.map(([key, value]) => [key, String(value)])) + } + + return mergeBranches({ + projectId, + sourceBranch, + targetBranch, + commitMessage: MainProcessBridge.vcString(source.commitMessage), + resolutions, + }) + } + + handleEdgeVcListBranches = ( + _event: IpcMainInvokeEvent, + projectId: unknown, + ): Promise> => { + const id = MainProcessBridge.vcString(projectId) + + return id ? listBranches(id) : Promise.resolve(MainProcessBridge.VC_BAD_REQUEST) + } + + handleEdgeVcCreateBranch = ( + _event: IpcMainInvokeEvent, + projectId: unknown, + name: unknown, + ): Promise> => { + const id = MainProcessBridge.vcString(projectId) + const branchName = MainProcessBridge.vcString(name) + + return id && branchName ? createBranch(id, branchName) : Promise.resolve(MainProcessBridge.VC_BAD_REQUEST) + } + + handleEdgeVcDeleteBranch = ( + _event: IpcMainInvokeEvent, + projectId: unknown, + branchId: unknown, + ): Promise> => { + const id = MainProcessBridge.vcString(projectId) + const branch = MainProcessBridge.vcString(branchId) + + return id && branch ? deleteBranch(id, branch) : Promise.resolve(MainProcessBridge.VC_BAD_REQUEST) + } + + handleEdgeVcSwitchBranch = ( + _event: IpcMainInvokeEvent, + projectId: unknown, + branchName: unknown, + strategy: unknown, + ): Promise> => { + const id = MainProcessBridge.vcString(projectId) + const name = MainProcessBridge.vcString(branchName) + + if (!id || !name) { + return Promise.resolve(MainProcessBridge.VC_BAD_REQUEST) + } + + // Anything but an explicit 'carry' discards, which is the endpoint's own default and + // the safer reading of a malformed value: carrying edits on a guess could move work + // onto a branch the user did not mean to touch. + return switchBranch(id, name, strategy === 'carry' ? 'carry' : 'discard') + } + + handleEdgeVcPreviewSwitchCarry = ( + _event: IpcMainInvokeEvent, + projectId: unknown, + targetBranch: unknown, + ): Promise> => { + const id = MainProcessBridge.vcString(projectId) + const target = MainProcessBridge.vcString(targetBranch) + + return id && target ? previewSwitchCarry(id, target) : Promise.resolve(MainProcessBridge.VC_BAD_REQUEST) + } + + handleEdgeVcListCommits = ( + _event: IpcMainInvokeEvent, + projectId: unknown, + options: unknown, + ): Promise> => { + const id = MainProcessBridge.vcString(projectId) + + if (!id) { + return Promise.resolve(MainProcessBridge.VC_BAD_REQUEST) + } + + const source = MainProcessBridge.vcRecord(options) + const limit = typeof source.limit === 'number' && Number.isInteger(source.limit) ? source.limit : undefined + const offset = typeof source.offset === 'number' && Number.isInteger(source.offset) ? source.offset : undefined + + return listCommits(id, { limit, offset, branch: MainProcessBridge.vcString(source.branch) }) + } + + handleEdgeVcCreateCommit = ( + _event: IpcMainInvokeEvent, + projectId: unknown, + message: unknown, + files: unknown, + branch: unknown, + ): Promise> => { + const id = MainProcessBridge.vcString(projectId) + const commitMessage = MainProcessBridge.vcString(message) + + return id && commitMessage + ? createCommit(id, commitMessage, MainProcessBridge.vcStringArray(files), MainProcessBridge.vcString(branch)) + : Promise.resolve(MainProcessBridge.VC_BAD_REQUEST) + } + + handleEdgeVcGetCommitFiles = ( + _event: IpcMainInvokeEvent, + projectId: unknown, + hash: unknown, + branch: unknown, + ): Promise> => { + const id = MainProcessBridge.vcString(projectId) + const commitHash = MainProcessBridge.vcString(hash) + + return id && commitHash + ? getCommitFiles(id, commitHash, MainProcessBridge.vcString(branch)) + : Promise.resolve(MainProcessBridge.VC_BAD_REQUEST) + } + + handleEdgeVcRestoreCommit = ( + _event: IpcMainInvokeEvent, + projectId: unknown, + hash: unknown, + branch: unknown, + ): Promise> => { + const id = MainProcessBridge.vcString(projectId) + const commitHash = MainProcessBridge.vcString(hash) + + return id && commitHash + ? restoreCommit(id, commitHash, MainProcessBridge.vcString(branch)) + : Promise.resolve(MainProcessBridge.VC_BAD_REQUEST) + } + + handleEdgeVcGetChanges = ( + _event: IpcMainInvokeEvent, + projectId: unknown, + includeContent: unknown, + ): Promise> => { + const id = MainProcessBridge.vcString(projectId) + + return id ? getChanges(id, includeContent === true) : Promise.resolve(MainProcessBridge.VC_BAD_REQUEST) + } + + handleEdgeVcDiscardChanges = ( + _event: IpcMainInvokeEvent, + projectId: unknown, + files: unknown, + ): Promise> => { + const id = MainProcessBridge.vcString(projectId) + + if (!id) { + return Promise.resolve(MainProcessBridge.VC_BAD_REQUEST) + } + + // A malformed list is refused rather than dropped: silently discarding nothing when + // the user asked to discard three files would look like the button is broken, and + // silently discarding everything would destroy work. + if (files !== undefined && MainProcessBridge.vcStringArray(files) === undefined) { + return Promise.resolve(MainProcessBridge.VC_BAD_REQUEST) + } + + return discardChanges(id, MainProcessBridge.vcStringArray(files)) + } + + handleEdgeVcListStashes = ( + _event: IpcMainInvokeEvent, + projectId: unknown, + ): Promise> => { + const id = MainProcessBridge.vcString(projectId) + + return id ? listStashes(id) : Promise.resolve(MainProcessBridge.VC_BAD_REQUEST) + } + + handleEdgeVcCreateStash = ( + _event: IpcMainInvokeEvent, + projectId: unknown, + message: unknown, + files: unknown, + ): Promise> => { + const id = MainProcessBridge.vcString(projectId) + + return id + ? createStash(id, MainProcessBridge.vcString(message), MainProcessBridge.vcStringArray(files)) + : Promise.resolve(MainProcessBridge.VC_BAD_REQUEST) + } + + handleEdgeVcApplyStash = ( + _event: IpcMainInvokeEvent, + projectId: unknown, + ref: unknown, + ): Promise> => { + const id = MainProcessBridge.vcString(projectId) + const stashRef = MainProcessBridge.vcString(ref) + + return id && stashRef ? applyStash(id, stashRef) : Promise.resolve(MainProcessBridge.VC_BAD_REQUEST) + } + + handleEdgeVcPopStash = ( + _event: IpcMainInvokeEvent, + projectId: unknown, + ref: unknown, + ): Promise> => { + const id = MainProcessBridge.vcString(projectId) + const stashRef = MainProcessBridge.vcString(ref) + + return id && stashRef ? popStash(id, stashRef) : Promise.resolve(MainProcessBridge.VC_BAD_REQUEST) + } + + handleEdgeVcDropStash = ( + _event: IpcMainInvokeEvent, + projectId: unknown, + ref: unknown, + ): Promise> => { + const id = MainProcessBridge.vcString(projectId) + const stashRef = MainProcessBridge.vcString(ref) + + return id && stashRef ? dropStash(id, stashRef) : Promise.resolve(MainProcessBridge.VC_BAD_REQUEST) + } + handleCatalogList = async ( _event: IpcMainInvokeEvent, args: ListPublicLibrariesArgs, diff --git a/src/main/modules/ipc/renderer.ts b/src/main/modules/ipc/renderer.ts index 076d523c0..169ce857e 100644 --- a/src/main/modules/ipc/renderer.ts +++ b/src/main/modules/ipc/renderer.ts @@ -5,6 +5,7 @@ import type { DeviceLicenseReport, DeviceLicenseRequest, } from '@root/middleware/shared/ports/device-port' +import type { EdgeSignInOutcome, EdgeUserRead } from '@root/middleware/shared/ports/edge-account-port' import type { ESIDevice, ESIRepositoryItemLight } from '@root/middleware/shared/ports/esi-types' import type { EtherCATRuntimeStatusResponse, @@ -17,6 +18,14 @@ import type { EtherCATValidateResponse, NetworkInterface, } from '@root/middleware/shared/ports/ethercat-types' +import type { + CloudFoldersResult, + CloudProjectsResult, + RawProjectFiles, + UploadProjectParams, + UploadProjectResult, + WriteProjectFiles, +} from '@root/middleware/shared/ports/project-port' import type { ListPublicLibrariesArgs, ListPublicLibrariesResponse, @@ -30,6 +39,17 @@ import type { } from '@root/middleware/shared/ports/runtime-port' import type { DebugConnectionConfig } from '@root/middleware/shared/ports/types' import type { PLCProjectData } from '@root/middleware/shared/ports/types' +import type { + Branch, + BranchDiffWithBase, + Commit, + CommitFile, + CommitInfo, + MergeResult, + PendingChange, + Stash, + VersionControlResult, +} from '@root/middleware/shared/ports/version-control-port' import { CreatePouFileProps, PouServiceResponse } from '@root/types/IPC/pou-service' import { CreateProjectFileProps, IProjectServiceResponse } from '@root/types/IPC/project-service' import { ipcRenderer, IpcRendererEvent } from 'electron' @@ -185,6 +205,108 @@ const rendererProcessBridge = { error?: string }> }> => ipcRenderer.invoke('catalog:install-many', libraries), + // ----- Edge account (optional sign-in, autonomy-edge) ----- + // Every call crosses to the main process because the desktop holds its own session: + // the renderer is not on Edge's origin, so it can neither inherit the shared-domain + // cookie the web editor uses nor issue the request itself. + edgeAccountFetchUser: (): Promise => ipcRenderer.invoke('edge-account:fetch-user'), + edgeAccountFetchPlanCaption: (): Promise => ipcRenderer.invoke('edge-account:fetch-plan-caption'), + edgeAccountSignIn: (email: string, password: string): Promise => + ipcRenderer.invoke('edge-account:sign-in', { email, password }), + edgeAccountSignOut: (): Promise => ipcRenderer.invoke('edge-account:sign-out'), + edgeAccountIsSessionPersistent: (): Promise => ipcRenderer.invoke('edge-account:is-session-persistent'), + // ----- Edge projects ----- + edgeProjectsListRecent: (limit: number): Promise => + ipcRenderer.invoke('edge-projects:list-recent', limit), + edgeProjectsRead: (projectId: string): Promise => + ipcRenderer.invoke('edge-projects:read', projectId), + edgeProjectsSaveProject: (files: WriteProjectFiles): Promise<{ success: boolean; error?: string }> => + ipcRenderer.invoke('edge-projects:save-project', files), + edgeProjectsSaveFile: (filePath: string, content: unknown): Promise<{ success: boolean; error?: string }> => + ipcRenderer.invoke('edge-projects:save-file', filePath, content), + // ----- Publishing a local project to Edge ----- + edgeUploadListFolders: (): Promise => ipcRenderer.invoke('edge-upload:list-folders'), + edgeUploadProject: (params: UploadProjectParams): Promise => + ipcRenderer.invoke('edge-upload:project', params), + // ----- Edge version control ----- + // One channel per operation, matching how `edge-account:*` and `edge-projects:*` are + // laid out. `EdgeVcResult` rather than a thrown error because a typed error class does + // not survive IPC: the adapter rebuilds the real error on the other side. + edgeVcListBranches: (projectId: string): Promise> => + ipcRenderer.invoke('edge-vc:list-branches', projectId), + edgeVcCreateBranch: (projectId: string, name: string): Promise> => + ipcRenderer.invoke('edge-vc:create-branch', projectId, name), + edgeVcDeleteBranch: (projectId: string, branchId: string): Promise> => + ipcRenderer.invoke('edge-vc:delete-branch', projectId, branchId), + edgeVcSwitchBranch: ( + projectId: string, + branchName: string, + strategy: 'discard' | 'carry', + ): Promise> => + ipcRenderer.invoke('edge-vc:switch-branch', projectId, branchName, strategy), + edgeVcPreviewSwitchCarry: ( + projectId: string, + targetBranch: string, + ): Promise> => + ipcRenderer.invoke('edge-vc:preview-switch-carry', projectId, targetBranch), + edgeVcListCommits: ( + projectId: string, + options: { limit?: number; offset?: number; branch?: string }, + ): Promise> => + ipcRenderer.invoke('edge-vc:list-commits', projectId, options), + edgeVcCreateCommit: ( + projectId: string, + message: string, + files?: string[], + branch?: string, + ): Promise> => + ipcRenderer.invoke('edge-vc:create-commit', projectId, message, files, branch), + edgeVcGetCommitFiles: ( + projectId: string, + hash: string, + branch?: string, + ): Promise> => + ipcRenderer.invoke('edge-vc:get-commit-files', projectId, hash, branch), + edgeVcRestoreCommit: ( + projectId: string, + hash: string, + branch?: string, + ): Promise> => + ipcRenderer.invoke('edge-vc:restore-commit', projectId, hash, branch), + edgeVcGetChanges: ( + projectId: string, + includeContent?: boolean, + ): Promise> => + ipcRenderer.invoke('edge-vc:get-changes', projectId, includeContent), + edgeVcDiscardChanges: (projectId: string, files?: string[]): Promise> => + ipcRenderer.invoke('edge-vc:discard-changes', projectId, files), + edgeVcListStashes: (projectId: string): Promise> => + ipcRenderer.invoke('edge-vc:list-stashes', projectId), + edgeVcCreateStash: ( + projectId: string, + message?: string, + files?: string[], + ): Promise> => + ipcRenderer.invoke('edge-vc:create-stash', projectId, message, files), + edgeVcApplyStash: (projectId: string, ref: string): Promise> => + ipcRenderer.invoke('edge-vc:apply-stash', projectId, ref), + edgeVcPopStash: (projectId: string, ref: string): Promise> => + ipcRenderer.invoke('edge-vc:pop-stash', projectId, ref), + edgeVcDropStash: (projectId: string, ref: string): Promise> => + ipcRenderer.invoke('edge-vc:drop-stash', projectId, ref), + edgeVcBranchDiffWithBase: ( + projectId: string, + source: string, + target: string, + ): Promise> => + ipcRenderer.invoke('edge-vc:branch-diff-with-base', projectId, source, target), + edgeVcMergeBranches: (params: { + projectId: string + sourceBranch: string + targetBranch: string + commitMessage?: string + resolutions?: Record + }): Promise> => ipcRenderer.invoke('edge-vc:merge-branches', params), onLibrariesChanged: (callback: () => void) => { const listener = () => callback() ipcRenderer.on('libraries:changed', listener) diff --git a/src/main/modules/store/index.ts b/src/main/modules/store/index.ts index 3b0e3fa69..5e94ea5c9 100644 --- a/src/main/modules/store/index.ts +++ b/src/main/modules/store/index.ts @@ -51,6 +51,22 @@ export const store = new Store({ }, }, }, + /** + * The Edge session. Declared so `electron-store` validates what it writes, but + * deliberately absent from `defaults`: no key at all is the signed-out state, and + * a default would make "never signed in" indistinguishable from "signed out". + * + * The value is a base64 `safeStorage` ciphertext, not the token itself. + */ + edge_session: { + type: 'object', + properties: { + refreshToken: { + type: 'string', + }, + }, + required: ['refreshToken'], + }, }, defaults: { last_projects: [], diff --git a/src/middleware/adapters/editor/__tests__/edge-account-adapter.test.ts b/src/middleware/adapters/editor/__tests__/edge-account-adapter.test.ts new file mode 100644 index 000000000..161c34701 --- /dev/null +++ b/src/middleware/adapters/editor/__tests__/edge-account-adapter.test.ts @@ -0,0 +1,256 @@ +/** + * The behaviour worth protecting is the session state machine, not the IPC + * forwarding. Three distinctions in it are load-bearing: + * + * - `unknown` (the question could not be asked) must NOT read as signed out, or a + * two-second network drop prompts over a live session holding unsaved work. + * - "never signed in" must not be worded as "your session expired", which is a claim + * about a session the user never had. + * - expiry must announce on the TRANSITION only. Firing on every failed read replays + * the handler on each poll. + */ + +import type { EdgeUserRead } from '../../../shared/ports/edge-account-port' +import { __resetEdgeSessionForTests, editorEdgeAccountPort, isSessionPersistent } from '../edge-account-adapter' + +const bridge = { + edgeAccountFetchUser: jest.fn, []>(), + edgeAccountFetchPlanCaption: jest.fn, []>(), + edgeAccountSignIn: jest.fn(), + edgeAccountSignOut: jest.fn, []>(), + edgeAccountIsSessionPersistent: jest.fn, []>(), +} + +const USER = { id: 'u1', name: 'Ada Lovelace', email: 'ada@example.com', username: 'ada' } + +beforeEach(() => { + jest.clearAllMocks() + __resetEdgeSessionForTests() + + bridge.edgeAccountFetchUser.mockResolvedValue({ status: 'no-session' }) + bridge.edgeAccountFetchPlanCaption.mockResolvedValue(null) + bridge.edgeAccountSignIn.mockResolvedValue({ status: 'signed-in', user: USER }) + bridge.edgeAccountSignOut.mockResolvedValue(undefined) + bridge.edgeAccountIsSessionPersistent.mockResolvedValue(true) + + window.bridge = bridge as unknown as typeof window.bridge +}) + +describe('static surface', () => { + it('exposes the Edge web origin through a getter', () => { + // A getter, not a captured value, so a build-time override is not frozen at import. + expect(editorEdgeAccountPort.frontendBaseUrl).toMatch(/^https?:\/\//) + }) + + it('lists the three providers Edge offers, in its own order', () => { + expect(editorEdgeAccountPort.oauthProviders.map((provider) => provider.id)).toEqual([ + 'google', + 'microsoft', + 'apple', + ]) + }) + + it('builds the provider address the Edge SPA would use', () => { + const url = new URL(editorEdgeAccountPort.oauthUrl('google', 'http://localhost:1313')) + + expect(url.pathname).toBe('/auth/google') + expect(url.searchParams.get('state')).toBe('http://localhost:1313') + }) +}) + +describe('fetchUser', () => { + it('returns the user and revives a previously dead session', async () => { + await editorEdgeAccountPort.fetchUser() + expect(editorEdgeAccountPort.session.isExpired()).toBe(true) + + bridge.edgeAccountFetchUser.mockResolvedValueOnce({ status: 'signed-in', user: USER }) + + await expect(editorEdgeAccountPort.fetchUser()).resolves.toEqual({ status: 'signed-in', user: USER }) + expect(editorEdgeAccountPort.session.isExpired()).toBe(false) + }) + + it('reports a first no-session as absent, not as an expiry', async () => { + await editorEdgeAccountPort.fetchUser() + + expect(editorEdgeAccountPort.session.isExpired()).toBe(true) + expect(editorEdgeAccountPort.session.isAbsent()).toBe(true) + }) + + it('reports a no-session AFTER a live session as an expiry', async () => { + bridge.edgeAccountFetchUser.mockResolvedValueOnce({ status: 'signed-in', user: USER }) + await editorEdgeAccountPort.fetchUser() + + bridge.edgeAccountFetchUser.mockResolvedValueOnce({ status: 'no-session' }) + await editorEdgeAccountPort.fetchUser() + + expect(editorEdgeAccountPort.session.isExpired()).toBe(true) + expect(editorEdgeAccountPort.session.isAbsent()).toBe(false) + }) + + it('leaves the session untouched on an unknown read', async () => { + bridge.edgeAccountFetchUser.mockResolvedValueOnce({ status: 'signed-in', user: USER }) + await editorEdgeAccountPort.fetchUser() + + const expiry = jest.fn() + editorEdgeAccountPort.session.onExpired(expiry) + + bridge.edgeAccountFetchUser.mockResolvedValueOnce({ status: 'unknown' }) + + await expect(editorEdgeAccountPort.fetchUser()).resolves.toEqual({ status: 'unknown' }) + expect(editorEdgeAccountPort.session.isExpired()).toBe(false) + expect(expiry).not.toHaveBeenCalled() + }) + + it('treats a throwing bridge call as unknown, not as signed out', async () => { + bridge.edgeAccountFetchUser.mockRejectedValueOnce(new Error('ipc gone')) + + await expect(editorEdgeAccountPort.fetchUser()).resolves.toEqual({ status: 'unknown' }) + expect(editorEdgeAccountPort.session.isExpired()).toBe(false) + }) + + it('announces expiry once, on the transition only', async () => { + const expiry = jest.fn() + editorEdgeAccountPort.session.onExpired(expiry) + + await editorEdgeAccountPort.fetchUser() + await editorEdgeAccountPort.fetchUser() + await editorEdgeAccountPort.fetchUser() + + expect(expiry).toHaveBeenCalledTimes(1) + }) +}) + +describe('fetchPlanCaption', () => { + it('passes the caption through', async () => { + bridge.edgeAccountFetchPlanCaption.mockResolvedValueOnce('Pro Plan') + + await expect(editorEdgeAccountPort.fetchPlanCaption()).resolves.toBe('Pro Plan') + }) + + it('degrades to null rather than taking the menu down', async () => { + bridge.edgeAccountFetchPlanCaption.mockRejectedValueOnce(new Error('nope')) + + await expect(editorEdgeAccountPort.fetchPlanCaption()).resolves.toBeNull() + }) +}) + +describe('signIn', () => { + it('announces restoration so a queued save can replay immediately', async () => { + await editorEdgeAccountPort.fetchUser() + + const restored = jest.fn() + editorEdgeAccountPort.session.onRestored(restored) + + await expect(editorEdgeAccountPort.signIn('ada@example.com', 'pw')).resolves.toEqual({ + status: 'signed-in', + user: USER, + }) + expect(restored).toHaveBeenCalledTimes(1) + }) + + it('passes a non-success outcome through untouched', async () => { + bridge.edgeAccountSignIn.mockResolvedValueOnce({ status: 'email-unverified', email: 'ada@example.com' }) + + await expect(editorEdgeAccountPort.signIn('ada@example.com', 'pw')).resolves.toEqual({ + status: 'email-unverified', + email: 'ada@example.com', + }) + }) + + it('reports a throwing bridge call as a failed sign-in', async () => { + bridge.edgeAccountSignIn.mockRejectedValueOnce(new Error('ipc gone')) + + await expect(editorEdgeAccountPort.signIn('ada@example.com', 'pw')).resolves.toEqual({ status: 'failed' }) + }) +}) + +describe('signOut', () => { + it('ends the session and words it as a departure, not an expiry', async () => { + bridge.edgeAccountFetchUser.mockResolvedValueOnce({ status: 'signed-in', user: USER }) + await editorEdgeAccountPort.fetchUser() + + const expiry = jest.fn() + editorEdgeAccountPort.session.onExpired(expiry) + + await editorEdgeAccountPort.signOut() + + expect(bridge.edgeAccountSignOut).toHaveBeenCalledTimes(1) + expect(editorEdgeAccountPort.session.isExpired()).toBe(true) + expect(editorEdgeAccountPort.session.isAbsent()).toBe(true) + expect(expiry).toHaveBeenCalledTimes(1) + }) + + it('still ends the local session when the request fails', async () => { + bridge.edgeAccountSignOut.mockRejectedValueOnce(new Error('offline')) + + await editorEdgeAccountPort.signOut() + + expect(editorEdgeAccountPort.session.isExpired()).toBe(true) + expect(editorEdgeAccountPort.session.isAbsent()).toBe(true) + }) +}) + +describe('isSessionPersistent', () => { + it('reports what the main process says', async () => { + bridge.edgeAccountIsSessionPersistent.mockResolvedValueOnce(false) + + await expect(isSessionPersistent()).resolves.toBe(false) + }) + + it('assumes not persistent when the probe fails', async () => { + bridge.edgeAccountIsSessionPersistent.mockRejectedValueOnce(new Error('nope')) + + // The safe direction: promising persistence we cannot confirm would surprise the + // user at the next launch. + await expect(isSessionPersistent()).resolves.toBe(false) + }) +}) + +describe('listener bookkeeping', () => { + it('unsubscribes both kinds of listener', async () => { + const expiry = jest.fn() + const restored = jest.fn() + + editorEdgeAccountPort.session.onExpired(expiry)() + editorEdgeAccountPort.session.onRestored(restored)() + + await editorEdgeAccountPort.fetchUser() + await editorEdgeAccountPort.signIn('ada@example.com', 'pw') + + expect(expiry).not.toHaveBeenCalled() + expect(restored).not.toHaveBeenCalled() + }) + + it('survives a listener that re-subscribes while being notified', async () => { + // The interrupted-save queue does exactly this when its replay fails a second + // time. Iterating a live Set turns re-registration into an unbounded loop, which + // is why the fan-out snapshots first. + let calls = 0 + + const resubscribe = () => { + calls += 1 + + if (calls < 5) { + editorEdgeAccountPort.session.onExpired(resubscribe) + } + } + + editorEdgeAccountPort.session.onExpired(resubscribe) + + await editorEdgeAccountPort.fetchUser() + + expect(calls).toBe(1) + }) + + it('markRestored announces nothing when nothing was announced dead', () => { + const restored = jest.fn() + editorEdgeAccountPort.session.onRestored(restored) + + editorEdgeAccountPort.session.markRestored() + + expect(restored).not.toHaveBeenCalled() + // It still retires `absent`, which is what stops the next expiry being worded as + // "you were never signed in". + expect(editorEdgeAccountPort.session.isAbsent()).toBe(false) + }) +}) diff --git a/src/middleware/adapters/editor/__tests__/navigation-adapter.test.ts b/src/middleware/adapters/editor/__tests__/navigation-adapter.test.ts index 927c1353d..926127e47 100644 --- a/src/middleware/adapters/editor/__tests__/navigation-adapter.test.ts +++ b/src/middleware/adapters/editor/__tests__/navigation-adapter.test.ts @@ -12,6 +12,17 @@ import type { NavigationPort } from '../../../shared/ports/navigation-port' import { createEditorNavigationAdapter } from '../navigation-adapter' +const openHistoryView = jest.fn() +const closeHistoryView = jest.fn() + +const openMergeView = jest.fn() + +jest.mock('../../../../frontend/store', () => ({ + useOpenPLCStore: { + getState: () => ({ versionControlActions: { openHistoryView, closeHistoryView, openMergeView } }), + }, +})) + interface WindowStub { location: { href: string } open: jest.Mock @@ -22,6 +33,7 @@ let stubWindow: WindowStub const originalWindow = (globalThis as { window?: unknown }).window beforeEach(() => { + jest.clearAllMocks() stubWindow = { location: { href: 'about:blank' }, open: jest.fn() } ;(globalThis as unknown as { window: WindowStub }).window = stubWindow adapter = createEditorNavigationAdapter() @@ -32,23 +44,36 @@ afterEach(() => { }) describe('navigate', () => { - it('builds and assigns a URL with search params to window.location.href', () => { + /** + * These two used to assert the opposite — that an unknown route was written to + * `location.href`. That WAS the behaviour, and it was the bug: inside the Electron + * renderer the assignment reloads the SPA shell, so pressing "Merge" closed the open + * project and dropped the user on the start screen with their unsaved edits gone. + */ + it('refuses an in-app route this build cannot render, leaving the app alone', () => { + // `/conflicts` has no desktop screen; `/history` and `/merge` do, and are covered + // separately. The refusal path still matters for whatever gets routed next. adapter.navigate('/conflicts', { branch: 'feat/foo' }) - expect(stubWindow.location.href).toContain('/conflicts') - expect(stubWindow.location.href).toContain('branch=') + expect(stubWindow.location.href).toBe('about:blank') }) - it('handles a navigation with no search params', () => { + it('refuses it whether or not there are search params', () => { adapter.navigate('/home') - expect(stubWindow.location.href).toContain('/home') + expect(stubWindow.location.href).toBe('about:blank') }) }) describe('openInNewWindow', () => { - it('opens the built URL in a new window', () => { - adapter.openInNewWindow('/diff', { commit: 'abc123' }) + /** + * These asserted that ANY path opened a window, `/diff` included. An in-app path no + * longer does: the desktop has no such route, so the window would show an empty page in + * development and a missing `file://` in a packaged build. An external URL — how the + * editor reaches Edge's own pages — still opens one, and that is the distinction now. + */ + it('opens an external URL in a new window, with its params', () => { + adapter.openInNewWindow('https://edge.example.com/diff', { commit: 'abc123' }) expect(stubWindow.open).toHaveBeenCalledTimes(1) const [url, target] = stubWindow.open.mock.calls[0] @@ -57,11 +82,106 @@ describe('openInNewWindow', () => { expect(target).toBe('_blank') }) - it('opens with no search params', () => { + it('refuses an in-app path instead of opening an empty window', () => { adapter.openInNewWindow('/diff') - expect(stubWindow.open).toHaveBeenCalledTimes(1) - const [, target] = stubWindow.open.mock.calls[0] - expect(target).toBe('_blank') + expect(stubWindow.open).not.toHaveBeenCalled() + }) +}) + +/** + * The desktop has no router, so a routed screen has to be rendered in place. This is the + * seam that makes that happen, and the reason it is tested here rather than in the + * component: getting it wrong does not look broken, it looks like a blank window. + */ +describe('the commit history screen is rendered in place, not navigated to', () => { + it('turns "view all files" into store state instead of a new window', () => { + adapter.openInNewWindow('/history', { project_id: 'p1', commit_hash: 'abc123', file: 'pous/programs/main.st' }) + + // A real window here would load a route this build does not have: an empty window in + // development, and a missing file:// URL in a packaged app. + expect(stubWindow.open).not.toHaveBeenCalled() + expect(openHistoryView).toHaveBeenCalledWith({ commitHash: 'abc123', file: 'pous/programs/main.st' }) + }) + + it('carries no file when none was asked for', () => { + adapter.openInNewWindow('/history', { project_id: 'p1', commit_hash: 'abc123' }) + + expect(openHistoryView).toHaveBeenCalledWith({ commitHash: 'abc123', file: undefined }) + }) + + it('intercepts an in-app navigation to the same screen', () => { + adapter.navigate('/history', { project_id: 'p1', commit_hash: 'abc123' }) + + // This one matters more than the window: `location.href` would RELOAD the renderer + // and take the open project down with it. + expect(stubWindow.location.href).toBe('about:blank') + expect(openHistoryView).toHaveBeenCalled() + }) + + it('does not open an empty screen when there is no commit to show', () => { + adapter.openInNewWindow('/history', { project_id: 'p1' }) + + // Nothing to show, and nothing to open: `/history` is an in-app path, so the request + // is declined rather than becoming a blank window. + expect(openHistoryView).not.toHaveBeenCalled() + expect(stubWindow.open).not.toHaveBeenCalled() + }) + + it('does not mistake the merge route for the history screen', () => { + adapter.navigate('/merge', { project_id: 'p1', source: 'feat' }) + + // Interception is exact: each route reaches its own screen, and neither falls through + // to a navigation. + expect(openHistoryView).not.toHaveBeenCalled() + expect(stubWindow.location.href).toBe('about:blank') + }) +}) + +/** + * The merge entry is the reason this file changed. It is the one caller that asked for a + * route the desktop has no screen for, and the old fallback answered by restarting the app. + */ +describe('the merge screen is rendered in place too', () => { + it('turns a merge request into store state instead of a navigation', () => { + adapter.navigate('/merge', { project_id: 'p1', source: 'feat', target: 'main' }) + + // This used to write `location.href`, which reloaded the renderer and closed the open + // project. It is now the same interception `/history` gets. + expect(openMergeView).toHaveBeenCalledWith({ sourceBranch: 'feat', targetBranch: 'main' }) + expect(stubWindow.location.href).toBe('about:blank') + expect(stubWindow.open).not.toHaveBeenCalled() + }) + + it('accepts a merge with no target, which the screen resolves itself', () => { + adapter.navigate('/merge', { project_id: 'p1', source: 'feat' }) + + // Legitimately absent when merge is opened from the branch you are on; the screen + // falls back to the default branch, as the web page does. + expect(openMergeView).toHaveBeenCalledWith({ sourceBranch: 'feat', targetBranch: undefined }) + }) + + it('declines a merge with no source branch rather than opening an empty screen', () => { + adapter.navigate('/merge', { project_id: 'p1' }) + + expect(openMergeView).not.toHaveBeenCalled() + expect(stubWindow.location.href).toBe('about:blank') + }) + + it('does not open a window onto an in-app path either', () => { + adapter.openInNewWindow('/merge', { project_id: 'p1' }) + + // A BrowserWindow pointed at a route this build lacks is an empty page in development + // and a missing file:// in a packaged app. + expect(stubWindow.open).not.toHaveBeenCalled() + }) + + it('still opens a real external link', () => { + adapter.openInNewWindow('https://edge.example.com/signup') + + // How the editor reaches Edge's own pages — sign-up, profile. Refusing these would + // have traded one broken affordance for another. + expect(stubWindow.open).toHaveBeenCalled() + expect(String(stubWindow.open.mock.calls[0][0])).toContain('edge.example.com/signup') }) }) diff --git a/src/middleware/adapters/editor/__tests__/project-adapter.test.ts b/src/middleware/adapters/editor/__tests__/project-adapter.test.ts index 01e0baa7f..938bccb7b 100644 --- a/src/middleware/adapters/editor/__tests__/project-adapter.test.ts +++ b/src/middleware/adapters/editor/__tests__/project-adapter.test.ts @@ -1,5 +1,10 @@ import type { ProjectPort } from '../../../shared/ports/project-port' -import { createEditorProjectAdapter, mapIpcPouToPortPou, mapPortPouToIpcPou } from '../project-adapter' +import { + createEditorProjectAdapter, + isCloudProjectId, + mapIpcPouToPortPou, + mapPortPouToIpcPou, +} from '../project-adapter' const mockIpcProjectResponse = { success: true, @@ -124,6 +129,10 @@ beforeEach(() => { }), pickPlcopenImportFile: jest.fn().mockResolvedValue({ success: true, content: '' }), exportPlcopenFile: jest.fn().mockResolvedValue({ success: true }), + edgeProjectsListRecent: jest.fn().mockResolvedValue({ status: 'ok', projects: [] }), + edgeProjectsRead: jest.fn().mockResolvedValue(mockRawProjectFiles), + edgeProjectsSaveProject: jest.fn().mockResolvedValue({ success: true }), + edgeProjectsSaveFile: jest.fn().mockResolvedValue({ success: true }), } as unknown as typeof window.bridge }) @@ -733,3 +742,119 @@ describe('mapIpcPouToPortPou', () => { expect(result.documentation).toBe('Test documentation') }) }) + +/** + * The editor now opens projects from two worlds, and `project.meta.path` is the single + * identifier every save flows through. These cases pin down the one place that decides + * which world a project belongs to — get it wrong and a local save is sent to the API, + * or a cloud save is written to a directory that does not exist. + */ +describe('isCloudProjectId', () => { + it.each(['cmt7n5ke2077o07jofjr3dgr0', 'abc123', 'cmt7n5ke2077o07jofjr3dgr0/pous/programs/main.st'])( + 'treats %s as a cloud identifier', + (identifier) => { + expect(isCloudProjectId(identifier)).toBe(true) + }, + ) + + it.each([ + '/Users/ada/projects/mine', + '/home/ada/p', + 'C:\\Users\\ada\\projects', + 'C:/Users/ada/projects', + '\\\\server\\share\\project', + ])('treats %s as a local path', (identifier) => { + expect(isCloudProjectId(identifier)).toBe(false) + }) + + it('treats an empty identifier as neither', () => { + // The start screen's "no project open" state. Routing it to the API would turn an + // empty workspace into a request for a project with no id. + expect(isCloudProjectId('')).toBe(false) + }) +}) + +describe('cloud projects', () => { + let cloudAdapter: ProjectPort + + beforeEach(() => { + cloudAdapter = createEditorProjectAdapter() + }) + + /** + * Found by running the app, not by reading it: the preload bundle and the renderer + * bundle are built separately, and a renderer newer than the main process called a + * channel that did not exist. The rejection escaped a `useEffect` and took the whole + * start screen down — local projects included. A cloud list nobody asked for must + * never cost someone their local work. + */ + it('reports the channel unavailable when the bridge predates this feature', async () => { + const bridge = window.bridge as unknown as Record + delete bridge.edgeProjectsListRecent + + // Not `signed-out`: nobody asked about the session. The start screen hides the + // section entirely for this, rather than inviting a sign-in that cannot help. + await expect(cloudAdapter.listRecentCloudProjects?.(5)).resolves.toEqual({ status: 'unavailable' }) + }) + + it('lists the account projects through the bridge', async () => { + const summary = { id: 'cmt7', name: 'Irrigation', language: 'st', updatedAt: '2026-08-24T19:40:51.962Z' } + ;(window.bridge.edgeProjectsListRecent as jest.Mock).mockResolvedValueOnce({ status: 'ok', projects: [summary] }) + + await expect(cloudAdapter.listRecentCloudProjects?.(5)).resolves.toEqual({ status: 'ok', projects: [summary] }) + expect(window.bridge.edgeProjectsListRecent).toHaveBeenCalledWith(5) + }) + + it('reads a cloud project from the API and a local one from disk', async () => { + await cloudAdapter.openProjectByPath('cmt7n5ke2077o07jofjr3dgr0') + + expect(window.bridge.edgeProjectsRead).toHaveBeenCalledWith('cmt7n5ke2077o07jofjr3dgr0') + expect(window.bridge.readProjectFiles).not.toHaveBeenCalled() + + await cloudAdapter.openProjectByPath('/Users/ada/projects/mine') + + expect(window.bridge.readProjectFiles).toHaveBeenCalledWith('/Users/ada/projects/mine') + }) + + it('forwards a cloud read failure as the adapter response', async () => { + ;(window.bridge.edgeProjectsRead as jest.Mock).mockResolvedValueOnce({ + success: false, + error: { title: 'Failed to open project', description: 'Autonomy Edge answered 403.', status: 403 }, + }) + + const result = await cloudAdapter.openProjectByPath('cmt7n5ke2077o07jofjr3dgr0') + + expect(result.success).toBe(false) + // The status rides along so a caller can tell a permission denial from a broken + // project. + expect(result.error?.status).toBe(403) + }) + + it('saves a cloud project to the API and a local one to disk', async () => { + const files = { projectPath: 'cmt7n5ke2077o07jofjr3dgr0', deletions: [] } as never + + await expect(cloudAdapter.saveProject(files)).resolves.toEqual({ success: true }) + expect(window.bridge.edgeProjectsSaveProject).toHaveBeenCalledWith(files) + expect(window.bridge.writeProjectFiles).not.toHaveBeenCalled() + + const local = { projectPath: '/Users/ada/projects/mine', deletions: [] } as never + + await cloudAdapter.saveProject(local) + + expect(window.bridge.writeProjectFiles).toHaveBeenCalledWith(local) + }) + + it('saves a cloud file to the API and a local one to disk', async () => { + await cloudAdapter.saveFile('cmt7n5ke2077o07jofjr3dgr0/pous/programs/main.st', 'x := TRUE;') + + expect(window.bridge.edgeProjectsSaveFile).toHaveBeenCalledWith( + 'cmt7n5ke2077o07jofjr3dgr0/pous/programs/main.st', + 'x := TRUE;', + ) + expect(window.bridge.saveFile).not.toHaveBeenCalled() + + await cloudAdapter.saveFile('/Users/ada/projects/mine/pous/programs/main.st', 'x := TRUE;') + + expect(window.bridge.saveFile).toHaveBeenCalled() + }) +}) diff --git a/src/middleware/adapters/editor/__tests__/version-control-adapter.test.ts b/src/middleware/adapters/editor/__tests__/version-control-adapter.test.ts new file mode 100644 index 000000000..77e5ead76 --- /dev/null +++ b/src/middleware/adapters/editor/__tests__/version-control-adapter.test.ts @@ -0,0 +1,279 @@ +/** + * The editor's version-control adapter. + * + * This file exists for one reason above all others: the UI decides what to show by asking + * `error instanceof SwitchBranchCarryConflictError`, and IPC destroys that. A class sent + * through a structured clone arrives as a plain object, `instanceof` answers false, and a + * blocked branch switch stops offering the conflict dialog and starts looking like a + * button that does nothing at all. So the assertions below are about identity, not + * message text — a test that only checked the wording would still pass while the feature + * was broken. + * + * The rest guards the two things that are easy to get subtly wrong: a stale main process + * must produce an error rather than take the workspace down with it, and the `branch` + * argument on the working-tree calls must be swallowed here exactly as the web adapter + * swallows it. + */ + +import { + MergeConflictError, + StashConflictError, + SwitchBranchCarryConflictError, +} from '../../../shared/ports/version-control-port' +import { createEditorVersionControlAdapter } from '../version-control-adapter' + +const computeGraphicalDiffImpl = jest.fn((_original: string, _current: string, _path: string) => ({ + isLadder: true, +})) + +jest.mock('../../../../backend/shared/utils/graphical-diff', () => ({ + computeGraphicalDiff: (original: string, current: string, path: string) => + computeGraphicalDiffImpl(original, current, path), +})) + +/** Every channel the adapter binds, so a missing one is a deliberate act in a test. */ +const CHANNELS = [ + 'edgeVcListBranches', + 'edgeVcCreateBranch', + 'edgeVcDeleteBranch', + 'edgeVcSwitchBranch', + 'edgeVcPreviewSwitchCarry', + 'edgeVcListCommits', + 'edgeVcCreateCommit', + 'edgeVcGetCommitFiles', + 'edgeVcRestoreCommit', + 'edgeVcGetChanges', + 'edgeVcDiscardChanges', + 'edgeVcListStashes', + 'edgeVcCreateStash', + 'edgeVcApplyStash', + 'edgeVcPopStash', + 'edgeVcDropStash', + 'edgeVcBranchDiffWithBase', + 'edgeVcMergeBranches', +] as const + +type Bridge = Record + +function installBridge(): Bridge { + const bridge: Bridge = {} + + for (const name of CHANNELS) { + bridge[name] = jest.fn().mockResolvedValue({ ok: true, data: null }) + } + + Object.defineProperty(window, 'bridge', { value: bridge, writable: true, configurable: true }) + + return bridge +} + +let bridge: Bridge + +beforeEach(() => { + jest.clearAllMocks() + bridge = installBridge() +}) + +describe('a reported failure becomes the error the UI branches on', () => { + it('rebuilds a carry conflict, with its files', async () => { + bridge.edgeVcSwitchBranch.mockResolvedValueOnce({ + ok: false, + failure: { kind: 'carry-conflict', conflictedFiles: ['pous/programs/main.st', 'devices/configuration.json'] }, + }) + + const vc = createEditorVersionControlAdapter() + + // `instanceof`, not the message: that is precisely what the clone breaks, and it is + // what `branch-status-bar` tests to decide whether to reopen the conflict modal. + await expect(vc.switchBranch('p1', 'feature', 'carry')).rejects.toBeInstanceOf(SwitchBranchCarryConflictError) + + await expect(vc.switchBranch('p1', 'feature', 'carry')).resolves.toBeDefined() + }) + + it('carries the conflicted paths through, because the modal lists them', async () => { + const conflictedFiles = ['pous/programs/main.st'] + bridge.edgeVcSwitchBranch.mockResolvedValueOnce({ ok: false, failure: { kind: 'carry-conflict', conflictedFiles } }) + + const vc = createEditorVersionControlAdapter() + + await expect(vc.switchBranch('p1', 'feature', 'carry')).rejects.toMatchObject({ conflictedFiles }) + }) + + it('rebuilds a stash conflict for apply and for pop', async () => { + bridge.edgeVcApplyStash.mockResolvedValueOnce({ ok: false, failure: { kind: 'stash-conflict' } }) + bridge.edgeVcPopStash.mockResolvedValueOnce({ ok: false, failure: { kind: 'stash-conflict' } }) + + const vc = createEditorVersionControlAdapter() + + await expect(vc.applyStash('p1', 's1')).rejects.toBeInstanceOf(StashConflictError) + await expect(vc.popStash('p1', 's1')).rejects.toBeInstanceOf(StashConflictError) + }) + + it('does not dress an ordinary failure up as a conflict', async () => { + bridge.edgeVcSwitchBranch.mockResolvedValueOnce({ + ok: false, + failure: { kind: 'http', status: 409, message: 'Branch already exists' }, + }) + + const vc = createEditorVersionControlAdapter() + const error = await vc.switchBranch('p1', 'feature', 'carry').catch((e: unknown) => e) + + // Otherwise the conflict modal opens with nothing in it. + expect(error).not.toBeInstanceOf(SwitchBranchCarryConflictError) + expect(error).toBeInstanceOf(Error) + expect(error instanceof Error ? error.message : '').toBe('Branch already exists') + }) + + it('says plainly that there is no session', async () => { + bridge.edgeVcListBranches.mockResolvedValueOnce({ ok: false, failure: { kind: 'signed-out' } }) + + await expect(createEditorVersionControlAdapter().listBranches('p1')).rejects.toThrow( + 'Not signed in to Autonomy Edge.', + ) + }) + + it('says unreachable rather than implying the operation was refused', async () => { + bridge.edgeVcCreateCommit.mockResolvedValueOnce({ + ok: false, + failure: { kind: 'unreachable', message: 'ENOTFOUND' }, + }) + + // The commit may or may not have landed. Wording it as a refusal would be the + // dangerous direction to be wrong in. + await expect(createEditorVersionControlAdapter().createCommit('p1', 'msg')).rejects.toThrow( + /Could not reach Autonomy Edge/, + ) + }) +}) + +describe('a stale main process fails as an error, not as a crash', () => { + it('reports the missing channel by name', async () => { + delete bridge.edgeVcListBranches + + // A renderer bundle is not always paired with the main bundle beside it. Reading + // straight through would raise "... is not a function" inside a load effect and take + // the whole workspace down — which has already happened once, on the cloud list. + await expect(createEditorVersionControlAdapter().listBranches('p1')).rejects.toThrow( + /edge-vc:list-branches is missing/, + ) + }) + + it('still builds the adapter, so the rest of the workspace loads', () => { + delete bridge.edgeVcListBranches + + expect(() => createEditorVersionControlAdapter()).not.toThrow() + }) +}) + +describe('the calls reach the right channel with the right arguments', () => { + it('passes branch operations straight through', async () => { + const vc = createEditorVersionControlAdapter() + + await vc.createBranch('p1', 'feature') + expect(bridge.edgeVcCreateBranch).toHaveBeenCalledWith('p1', 'feature') + + await vc.deleteBranch('p1', 'b2') + expect(bridge.edgeVcDeleteBranch).toHaveBeenCalledWith('p1', 'b2') + + await vc.previewSwitchCarry('p1', 'feature') + expect(bridge.edgeVcPreviewSwitchCarry).toHaveBeenCalledWith('p1', 'feature') + }) + + it('defaults a switch to discard, matching the web adapter signature', async () => { + await createEditorVersionControlAdapter().switchBranch('p1', 'feature') + + // Carrying edits on an unstated strategy could move work onto a branch the user did + // not mean to touch. + expect(bridge.edgeVcSwitchBranch).toHaveBeenCalledWith('p1', 'feature', 'discard') + }) + + it('defaults commit options to an empty object rather than undefined', async () => { + await createEditorVersionControlAdapter().listCommits('p1') + + expect(bridge.edgeVcListCommits).toHaveBeenCalledWith('p1', {}) + }) + + it('drops the branch argument on the working-tree calls', async () => { + const vc = createEditorVersionControlAdapter() + + await vc.getChanges('p1', 'feature', true) + // The backend's whitelist rejects the param and computes against the checked-out + // HEAD anyway. Same omission the web adapter documents. + expect(bridge.edgeVcGetChanges).toHaveBeenCalledWith('p1', true) + + await vc.discardChanges('p1', ['a.st'], 'feature') + expect(bridge.edgeVcDiscardChanges).toHaveBeenCalledWith('p1', ['a.st']) + }) + + it('unwraps the data on success', async () => { + bridge.edgeVcListBranches.mockResolvedValueOnce({ ok: true, data: { branches: [{ id: 'b1' }] } }) + + await expect(createEditorVersionControlAdapter().listBranches('p1')).resolves.toEqual({ + branches: [{ id: 'b1' }], + }) + }) + + it('resolves the void operations without handing back the envelope', async () => { + bridge.edgeVcDropStash.mockResolvedValueOnce({ ok: true, data: null }) + + await expect(createEditorVersionControlAdapter().dropStash('p1', 's1')).resolves.toBeUndefined() + }) +}) + +describe('the graphical diff', () => { + it('runs locally, on the shared implementation', () => { + const vc = createEditorVersionControlAdapter() + + const result = vc.computeGraphicalDiff('', '', 'pous/programs/main.xml') + + // Synchronous by contract, and pure computation over content the caller already + // holds — sending a whole LD program across IPC to diff it would be slower and no + // more correct. Shared module, so the desktop and the web produce the same diff. + expect(computeGraphicalDiffImpl).toHaveBeenCalledWith('', '', 'pous/programs/main.xml') + expect(result).toEqual({ isLadder: true }) + }) +}) + +describe('the merge conflict crosses IPC as itself', () => { + it('rebuilds MergeConflictError, with the files the resolver needs', async () => { + bridge.edgeVcMergeBranches.mockResolvedValueOnce({ + ok: false, + failure: { + kind: 'merge-conflict', + conflictedFiles: ['pous/programs/main.st', 'devices/configuration.json'], + message: 'Merge conflicts detected', + }, + }) + + const vc = createEditorVersionControlAdapter() + const error = await vc + .mergeBranches?.({ projectId: 'p1', sourceBranch: 'feature', targetBranch: 'main' }) + .catch((e: unknown) => e) + + // `instanceof`, not the message: the screen opens its conflict resolver on the type, + // and the prototype is exactly what a structured clone destroys. + expect(error).toBeInstanceOf(MergeConflictError) + expect(error).toMatchObject({ conflictedFiles: ['pous/programs/main.st', 'devices/configuration.json'] }) + }) + + it('passes the merge through on success', async () => { + bridge.edgeVcMergeBranches.mockResolvedValueOnce({ + ok: true, + data: { message: 'Merged', mergeCommit: { shortHash: 'abc1234' } }, + }) + + const vc = createEditorVersionControlAdapter() + + await expect( + vc.mergeBranches?.({ projectId: 'p1', sourceBranch: 'feature', targetBranch: 'main' }), + ).resolves.toMatchObject({ message: 'Merged' }) + }) + + it('reaches the diff channel with both branches', async () => { + bridge.edgeVcBranchDiffWithBase.mockResolvedValueOnce({ ok: true, data: { conflicts: [] } }) + + await createEditorVersionControlAdapter().getBranchDiffWithBase?.('p1', 'feature', 'main') + + expect(bridge.edgeVcBranchDiffWithBase).toHaveBeenCalledWith('p1', 'feature', 'main') + }) +}) diff --git a/src/middleware/adapters/editor/edge-account-adapter.ts b/src/middleware/adapters/editor/edge-account-adapter.ts new file mode 100644 index 000000000..2efa6a584 --- /dev/null +++ b/src/middleware/adapters/editor/edge-account-adapter.ts @@ -0,0 +1,237 @@ +/** + * `EdgeAccountPort` for the desktop editor. + * + * Every call crosses to the main process, because the desktop holds its own session: + * the renderer is not on Edge's origin, so it can neither inherit the shared-domain + * cookie the web editor authenticates with nor issue the request itself. The main + * process owns the tokens, the renewal and the encrypted storage; this is the + * renderer's view of it. + * + * WHY THE SESSION STATE MACHINE LIVES HERE. On the web it belongs to the + * fetch-with-renewal layer, which is the thing that learns a session died. Here that + * layer is in the main process, so the renderer never observes a renewal failing. + * What it does observe is the ANSWER to "who is signed in", and that is enough to + * drive the same state: a definitive `no-session` after a live one is an expiry, and + * a `signed-in` read is a restoration. Deriving it from the outcomes this adapter + * already returns keeps one source of truth, instead of a second channel for the main + * process to push events over. + * + * SIGNING IN IS OPTIONAL. Nothing here runs unless the user asks. The editor opens, + * loads local projects and works offline with this file never touched. + */ + +import type { + EdgeAccountPort, + EdgeOAuthProviderId, + EdgeSessionState, + EdgeSignInOutcome, + EdgeUserRead, +} from '../../shared/ports/edge-account-port' +import { getEdgeWebUrl } from './system-adapter' + +/** The providers Edge offers, in the order its own sign-in screen lists them. */ +const EDGE_OAUTH_PROVIDERS = [ + { id: 'google', label: 'Google' }, + { id: 'microsoft', label: 'Microsoft' }, + { id: 'apple', label: 'Apple' }, +] as const + +// --------------------------------------------------------------------------- +// Session state +// --------------------------------------------------------------------------- + +/** True once a session has been observed to be gone for good. */ +let expired = false + +/** + * True while no session has been seen on this run. + * + * Kept apart from `expired` because the two are worded differently to the user and + * conflating them is a real bug: telling someone who never signed in that "your + * session has expired" is a claim about a session they never had. It is also what + * separates a deliberate sign-out from an expiry. + */ +let absent = true + +const expiryListeners = new Set<() => void>() +const restoredListeners = new Set<() => void>() + +/** + * Notify a listener set. + * + * Snapshotted before iterating. A listener may subscribe again while being notified — + * the interrupted-save queue does exactly that when its replay fails a second time — + * and a `Set` grown during `for..of` keeps handing out the newly added entries, which + * turns re-registration into an unbounded loop. + */ +function notify(listeners: Set<() => void>): void { + for (const listener of [...listeners]) { + listener() + } +} + +/** Record that the session is gone, and whether there was one to lose. */ +function markGone(neverHadOne: boolean): void { + const wasAlive = !expired + + expired = true + absent = neverHadOne + + // Only announce a transition. Firing on every failed read would replay the expiry + // handler on each poll. + if (wasAlive) { + notify(expiryListeners) + } +} + +const session: EdgeSessionState = { + isExpired: () => expired, + isAbsent: () => absent, + + onExpired(listener) { + expiryListeners.add(listener) + + return () => expiryListeners.delete(listener) + }, + + onRestored(listener) { + restoredListeners.add(listener) + + return () => restoredListeners.delete(listener) + }, + + /** + * Record that the session demonstrably works. Safe to call on any healthy read. + * + * `absent` is cleared unconditionally, and that is load-bearing rather than + * defensive: it means "no session has been seen", so observing a live one has to + * retire it even when nothing was announced dead. Otherwise the initial `true` + * survives a successful read and the NEXT expiry gets worded as "you were never + * signed in" to someone who demonstrably was. Only the ANNOUNCEMENT is conditional, + * because listeners care about the transition. + */ + markRestored() { + const wasDead = expired + + expired = false + absent = false + + if (wasDead) { + notify(restoredListeners) + } + }, +} + +// --------------------------------------------------------------------------- +// Port +// --------------------------------------------------------------------------- + +export const editorEdgeAccountPort: EdgeAccountPort = { + get frontendBaseUrl() { + // A getter, not a captured value: the URL comes from a build-time override, and + // freezing it at module load would pin whatever was configured at import time. + return getEdgeWebUrl() + }, + + oauthProviders: EDGE_OAUTH_PROVIDERS, + + /** + * Where the shared sign-in dialog points its provider links. + * + * The desktop never actually follows this. The dialog renders each provider as a + * `target='_blank'` link, and the main process intercepts that window-open: a browser + * tab's cookie jar is not ours to read, so the flow runs in a window we own instead + * (`main.ts` → `oauth-window.ts`). The URL is therefore a statement of intent, and + * the interception matches on its PATH — which is why building it from the web origin + * here is harmless, and why the renderer never needs to know the API origin. + */ + oauthUrl(provider: EdgeOAuthProviderId, returnTo: string): string { + return `${getEdgeWebUrl()}/auth/${provider}?${new URLSearchParams({ state: returnTo }).toString()}` + }, + + async fetchUser(): Promise { + let read: EdgeUserRead + + try { + read = await window.bridge.edgeAccountFetchUser() + } catch { + // An IPC call that threw tells us nothing about the session — the same standing + // as a network failure, and the caller must be able to hold its ground. + return { status: 'unknown' } + } + + if (read.status === 'signed-in') { + session.markRestored() + + return read + } + + if (read.status === 'no-session') { + markGone(absent) + } + + // `unknown` deliberately changes nothing: a request that never reached the server + // is not evidence that the session ended. + return read + }, + + fetchPlanCaption(): Promise { + // A caption is decoration beside the account name; a failure must not take the + // menu down with it. + return window.bridge.edgeAccountFetchPlanCaption().catch(() => null) + }, + + async signIn(email: string, password: string): Promise { + let outcome: EdgeSignInOutcome + + try { + outcome = await window.bridge.edgeAccountSignIn(email, password) + } catch { + return { status: 'failed' } + } + + if (outcome.status === 'signed-in') { + // Announced here rather than left for the next read to discover, so a save that + // died with the old session can run itself again immediately. + session.markRestored() + } + + return outcome + }, + + async signOut(): Promise { + try { + await window.bridge.edgeAccountSignOut() + } catch { + // The local session ends regardless: someone who asked to sign out must end up + // signed out even if the request never landed. + } + + // Absent, not expired: this was a deliberate departure, and wording it as an + // expiry would tell the user something untrue about their session. + expired = true + absent = true + notify(expiryListeners) + }, + + session, +} + +/** + * Whether a session on this machine survives a restart. + * + * False on a Linux box with no keyring, where the refresh token is deliberately not + * written to disk. Worth telling the user, because "you will have to sign in again + * next time" is surprising otherwise. + */ +export function isSessionPersistent(): Promise { + return window.bridge.edgeAccountIsSessionPersistent().catch(() => false) +} + +/** Test seam: return the module to its just-loaded state. */ +export function __resetEdgeSessionForTests(): void { + expired = false + absent = true + expiryListeners.clear() + restoredListeners.clear() +} diff --git a/src/middleware/adapters/editor/navigation-adapter.ts b/src/middleware/adapters/editor/navigation-adapter.ts index d2fdb9981..28f27ed32 100644 --- a/src/middleware/adapters/editor/navigation-adapter.ts +++ b/src/middleware/adapters/editor/navigation-adapter.ts @@ -1,41 +1,134 @@ /** * Editor NavigationPort adapter. * - * The Electron editor has no SPA router — navigation is tab-driven via the - * Zustand `tabs`/`editor` slices. The routed features that drive shared - * navigation calls (the merge page, the history page) are gated away by - * `capabilities.hasVersionControl=false`, so in practice these methods - * never fire from the editor build. + * The Electron editor has no SPA router — navigation is tab-driven via the Zustand + * `tabs`/`editor` slices. So a routed feature the shared UI asks for cannot be reached by + * changing a URL, and what happens instead depends on the kind of destination: + * - An IN-APP PATH the desktop cannot render is REFUSED. It used to be written to + * `window.location.href`, which in the Electron renderer reloads the whole SPA shell: + * the open project was closed, unsaved edits went with it, and the user landed back on + * the start screen having pressed a button labelled "Merge". A deterministic outcome + * was the intent, but discarding someone's work is not an outcome worth having, and + * nothing about it told them what had happened. Doing nothing and saying so is the + * lesser failure. + * - An EXTERNAL URL still opens a window. `openInNewWindow` is how the editor links out + * to Edge's own pages (sign-up, profile), and that has always worked. * - * The fallbacks below exist purely so the port satisfies its contract for - * any code path that does call into it (a no-op would silently swallow a - * navigation request, which is harder to debug): - * - `navigate` writes to `window.location.href`. Inside the Electron - * renderer this reloads the SPA shell at the requested path; for - * unknown routes it simply lands back on the index, but the user - * gets a deterministic outcome instead of nothing happening. - * - `openInNewWindow` calls `window.open(url, '_blank')`. Electron - * translates this into a fresh `BrowserWindow`, matching what the - * existing "Open in new tab" affordances did before this port. + * `/history` AND `/merge` ARE INTERCEPTED, and that is the point of this file now. The commit's + * full-file view is a real screen the desktop has to offer: source control is on for cloud + * projects, so "View all files" is reachable, and a `window.open('/history?…')` would open + * a BrowserWindow onto a route that does not exist — an empty window in development and a + * missing `file://` in a packaged build. Rather than degrade the web (where it opens a + * genuine second tab and the workspace stays put), the platform difference lives here: + * the request becomes store state, and the workspace screen lays the same shared + * `CommitHistoryView` over itself. + * + * This is the only adapter that writes to the store, which is worth stating plainly: the + * alternative was for the shared component to know which product it is running in, and + * keeping that knowledge here is exactly why the port exists. */ +import { useOpenPLCStore } from '../../../frontend/store' import type { NavigationPort, NavigationSearch } from '../../shared/ports/navigation-port' import { buildNavigationUrl } from '../../shared/ports/navigation-port' +/** The routed screens the desktop renders in place rather than navigating to. */ +const HISTORY_PATH = '/history' +const MERGE_PATH = '/merge' + +/** + * Decline a destination this build has no screen for. + * + * Deliberately not a thrown error: every caller is a click handler in shared UI that does + * not expect navigation to fail, and an exception there would surface as an unhandled + * rejection rather than as anything the user can read. The warning is for whoever adds the + * next routed feature — it names the path, so the missing interception is obvious. + */ +function refuse(path: string): void { + console.warn(`[navigation] no desktop screen for "${path}" — request ignored rather than reloading the app.`) +} + export function createEditorNavigationAdapter(): NavigationPort { + /** + * Reads the same two params the `/history` route declares, so the shared caller does + * not need to know it is being intercepted. `commit_hash` is the only required one — + * without it there is no commit to show, and opening an empty screen would be worse + * than leaving the click unanswered. + */ + const openHistory = (search?: NavigationSearch): boolean => { + const commitHash = search?.commit_hash + + if (!commitHash) { + return false + } + + useOpenPLCStore.getState().versionControlActions.openHistoryView({ commitHash, file: search?.file }) + + return true + } + + /** + * Reads the two params the `/merge` route declares. `source` is the only required one — + * without a branch to merge there is nothing to show, and `target` is legitimately + * absent when the user opens merge from the branch they are on: the screen then falls + * back to the default branch, exactly as the web page does. + */ + const openMerge = (search?: NavigationSearch): boolean => { + const sourceBranch = search?.source + + if (!sourceBranch) { + return false + } + + useOpenPLCStore.getState().versionControlActions.openMergeView({ sourceBranch, targetBranch: search?.target }) + + return true + } + return { navigate(path: string, search?: NavigationSearch): void { - window.location.href = buildNavigationUrl(path, search) + if (path === HISTORY_PATH && openHistory(search)) { + return + } + + if (path === MERGE_PATH && openMerge(search)) { + return + } + + // Refused, not reloaded. See the note at the top of this file: assigning + // `location.href` here restarted the renderer and took the open project with it. + // A caller that reaches this line is asking for a screen this build does not have, + // and the honest answer is to decline — loudly in the log, so the gap is findable, + // and without touching what the user has open. + refuse(path) }, openInNewWindow(path: string, search?: NavigationSearch): void { - window.open(buildNavigationUrl(path, search), '_blank') + if (path === HISTORY_PATH && openHistory(search)) { + return + } + + if (path === MERGE_PATH && openMerge(search)) { + return + } + + // An absolute URL is a link out of the app — Edge's sign-up and profile pages come + // through here — and a real window is the right answer for it. An in-app path is + // the same missing-screen case as above: a `BrowserWindow` pointed at it shows an + // empty page in development and a missing `file://` in a packaged build. + if (/^[a-z][a-z0-9+.-]*:/i.test(path)) { + window.open(buildNavigationUrl(path, search), '_blank') + + return + } + + refuse(path) }, exitToHost(): void { - // The editor has no host to return to — the start screen appears - // automatically once `clearStatesOnCloseProject` has reset project - // state, so this is intentionally a no-op. + // The editor has no host to return to — the start screen appears automatically + // once `clearStatesOnCloseProject` has reset project state, so this is + // intentionally a no-op. }, } } diff --git a/src/middleware/adapters/editor/project-adapter.ts b/src/middleware/adapters/editor/project-adapter.ts index 5af2c5d88..bcb1c4bd6 100644 --- a/src/middleware/adapters/editor/project-adapter.ts +++ b/src/middleware/adapters/editor/project-adapter.ts @@ -12,12 +12,16 @@ import { parseProjectFiles } from '../../../backend/shared/utils/parse-project-files' import type { + CloudFoldersResult, + CloudProjectsResult, CreatePouParams, CreateProjectParams, ProjectPort, ProjectResponse, RawProjectFiles, RenamePouParams, + UploadProjectParams, + UploadProjectResult, WriteProjectFiles, } from '../../shared/ports/project-port' import type { @@ -32,6 +36,7 @@ import type { RecentProject, Unsubscribe, } from '../../shared/ports/types' +import { isRemoteProjectPath } from '../../shared/ports/types' /** Editor IPC POU shape (discriminated union). */ interface IpcPou { @@ -173,6 +178,17 @@ function mapIpcResponse( } } +/** + * Whether an identifier names a project on Autonomy Edge rather than one on disk. + * + * The editor opens both, and `project.meta.path` is the single identifier every save flows + * through — so this decides which world a project belongs to. It delegates rather than + * deciding: the shared UI needs the same answer to know whether to offer version control, + * and two copies of this test would eventually disagree about a Windows path and send a + * save to the wrong place. The name stays because the save flow reads better for it. + */ +export const isCloudProjectId = isRemoteProjectPath + export function createEditorProjectAdapter(): ProjectPort { return { async createProject(params: CreateProjectParams): Promise { @@ -218,8 +234,12 @@ export function createEditorProjectAdapter(): ProjectPort { }, async openProjectByPath(projectPath: string): Promise { - // Read raw files and parse on the frontend - const raw = (await window.bridge.readProjectFiles(projectPath)) as RawProjectFiles + // Read raw files and parse on the frontend. The parsing below is identical either + // way — only where the bytes come from differs, which is the whole point of the + // cloud reader returning the same `RawProjectFiles` the filesystem one does. + const raw = isCloudProjectId(projectPath) + ? await window.bridge.edgeProjectsRead(projectPath) + : ((await window.bridge.readProjectFiles(projectPath)) as RawProjectFiles) if (!raw.success || !raw.data) { return { success: false, error: raw.error } } @@ -239,7 +259,30 @@ export function createEditorProjectAdapter(): ProjectPort { // version-skewed main process must not crash project open. Array.isArray(raw.data.dataTypeFiles) ? raw.data.dataTypeFiles : [], ) - return { success: true, data: parsed } + return { + success: true, + data: { + ...parsed, + /** + * Carried through so the save flow can echo unedited files back byte-for-byte + * instead of re-serialising them. `RawProjectFiles` only has it for a cloud + * project — the filesystem reader has the files on disk and no separate notion of + * "as loaded" — so it is absent for a local one, which the sync point treats the + * same as having nothing to echo. + */ + rawLoadedFiles: raw.data.rawLoadedFiles, + /** + * Whether this account may persist changes, straight from the server's own + * capabilities. Dropping it made the store fall back to "editable", which left + * the read-only guards dead on the desktop: a viewer saw Commit, Discard and + * Restore enabled and found out only when Edge refused the write. + * + * Absent for a project on disk, where there is no remote permission to speak of, + * and the store reads absent as editable — which is correct there. + */ + canEdit: raw.data.canEdit, + }, + } }, async readProjectFiles(projectPath: string): Promise { @@ -247,6 +290,10 @@ export function createEditorProjectAdapter(): ProjectPort { }, async saveProject(files: WriteProjectFiles): Promise<{ success: boolean; error?: string }> { + if (isCloudProjectId(files.projectPath)) { + return window.bridge.edgeProjectsSaveProject(files) + } + const response = (await window.bridge.writeProjectFiles(files)) as { success: boolean; error?: string } if (!response.success) { return { success: false, error: response.error ?? 'Save failed' } @@ -255,6 +302,12 @@ export function createEditorProjectAdapter(): ProjectPort { }, async saveFile(filePath: string, content: unknown): Promise<{ success: boolean; error?: string }> { + // `projectId/relative/path` for a cloud project, an absolute path for a local one. + // Both arrive here from the same shared save flow. + if (isCloudProjectId(filePath)) { + return window.bridge.edgeProjectsSaveFile(filePath, content) + } + return window.bridge.saveFile(filePath, content) }, @@ -316,6 +369,70 @@ export function createEditorProjectAdapter(): ProjectPort { return window.bridge.pathPicker() }, + /** + * Where a local project can be published. Guarded like `listRecentCloudProjects`: a + * renderer paired with a main process that predates this channel would otherwise raise + * "is not a function" and take the start screen down over a menu item nobody clicked. + */ + async listCloudFolders(): Promise { + if (typeof window.bridge.edgeUploadListFolders !== 'function') { + return { status: 'unreachable' } + } + + const result = await window.bridge.edgeUploadListFolders().catch( + (): CloudFoldersResult => ({ + status: 'unreachable', + }), + ) + + // Shape-checked, not trusted: a stale main bundle answering with something else must + // not become an empty folder list, which would read as "you have no folders". + return typeof result === 'object' && result !== null && 'status' in result ? result : { status: 'unreachable' } + }, + + async uploadProjectToCloud(params: UploadProjectParams): Promise { + if (typeof window.bridge.edgeUploadProject !== 'function') { + return { + status: 'failed', + failure: { reason: 'unreadable', message: 'This build of the editor cannot publish to Autonomy Edge.' }, + } + } + + return window.bridge.edgeUploadProject(params).catch( + (error: unknown): UploadProjectResult => ({ + status: 'failed', + // A rejection here is the IPC call itself failing, which says nothing about + // whether the import ran. Reported as unreachable for that reason. + failure: { reason: 'unreachable', message: error instanceof Error ? error.message : 'The upload failed.' }, + }), + ) + }, + + listRecentCloudProjects(limit: number): Promise { + // Guarded, not assumed: the preload bundle and the renderer bundle are built + // separately and can skew — a running app whose main process predates this + // feature has no such channel. `unavailable` is the honest answer there, and it + // is what stops a missing channel taking the whole start screen down with it. + if (typeof window.bridge.edgeProjectsListRecent !== 'function') { + return Promise.resolve({ status: 'unavailable' }) + } + + // The SHAPE is checked too, not just the presence of the function. An older main + // process answers with a bare array, and an unrecognised shape falls through every + // branch of the section's state machine into "no cloud projects yet" — telling a + // signed-out user their account is empty. Observed, not imagined: it is what a + // stale bundle did on the first run of this code. + return window.bridge.edgeProjectsListRecent(limit).then((result): CloudProjectsResult => { + const status = (result as { status?: unknown } | null)?.status + + if (status === 'ok' || status === 'signed-out' || status === 'unreachable' || status === 'unavailable') { + return result + } + + return { status: 'unavailable' } + }) + }, + async getRecentProjects(): Promise { return window.bridge.retrieveRecent() }, diff --git a/src/middleware/adapters/editor/system-adapter.ts b/src/middleware/adapters/editor/system-adapter.ts index e58bd3bbb..2aaeaffec 100644 --- a/src/middleware/adapters/editor/system-adapter.ts +++ b/src/middleware/adapters/editor/system-adapter.ts @@ -35,6 +35,17 @@ const PRODUCTION_EDGE_WEB_URL = 'https://edge.autonomylogic.com' */ const EDGE_WEB_URL = process.env.OPENPLC_EDGE_WEB_URL || PRODUCTION_EDGE_WEB_URL +/** + * The Edge SPA origin, for anything in the editor that links out to it. + * + * Exported so the Edge account adapter resolves the same value from the same + * override rather than re-deriving it — two copies would drift the moment one of + * them gained a fallback the other did not. + */ +export function getEdgeWebUrl(): string { + return EDGE_WEB_URL +} + export function createEditorSystemAdapter(): SystemPort { return { getSystemInfo(): Promise { diff --git a/src/middleware/adapters/editor/version-control-adapter.ts b/src/middleware/adapters/editor/version-control-adapter.ts index 830cdfb21..e03c11857 100644 --- a/src/middleware/adapters/editor/version-control-adapter.ts +++ b/src/middleware/adapters/editor/version-control-adapter.ts @@ -1,36 +1,194 @@ /** - * Editor VersionControlPort adapter — no-op implementation. + * Editor VersionControlPort adapter — Autonomy Edge, over IPC. * - * Version control is not supported on the desktop editor. - * All methods return empty results or throw to indicate unsupported operations. - * The UI guards these calls behind `capabilities.hasVersionControl === true`, - * so these methods should never be reached at runtime. + * Version control on the desktop is the same feature the web editor has, because it is + * the same server doing the work: the git repository lives beside the project on Edge and + * every operation here is one of the seventeen routes the web build calls. Nothing about + * branching, carrying edits between branches or stashing is reimplemented locally, so the + * two products cannot drift apart in behaviour — there is only one implementation of it. + * + * That also fixes the boundary of the feature. A project opened from disk has no + * repository anywhere, so it has no history to show; the shared UI gates the whole + * affordance on `isRemoteProjectPath(projectPath)` and never calls into here for one. + * + * REBUILDING THE TYPED ERRORS IS THE POINT OF THIS FILE. The main process cannot throw + * `SwitchBranchCarryConflictError` at the renderer: IPC structure-clones the value and the + * prototype does not survive, so every `instanceof` in the UI would quietly answer false + * and a blocked branch switch would look like a button that does nothing. The main process + * therefore reports failures as plain data, and `unwrap` below turns them back into the + * exact error objects the components already branch on. The web adapter gets this for free + * from axios; the desktop has to do it by hand, and doing it here keeps the components + * identical between the two. */ -import type { GraphicalDiffResult, VersionControlPort } from '../../shared/ports/version-control-port' +import { computeGraphicalDiff as computeGraphicalDiffImpl } from '../../../backend/shared/utils/graphical-diff' +import type { + BranchDiffWithBase, + Commit, + GraphicalDiffResult, + ListCommitsOptions, + MergeResult, + SwitchBranchStrategy, + VersionControlPort, + VersionControlResult, +} from '../../shared/ports/version-control-port' +import { + MergeConflictError, + StashConflictError, + SwitchBranchCarryConflictError, +} from '../../shared/ports/version-control-port' -export function createEditorVersionControlAdapter(): VersionControlPort { - const unsupported = (method: string): never => { - throw new Error(`VersionControl.${method}() is not supported in the desktop editor`) +/** + * Turn a reported failure back into the error the UI expects, or hand back the data. + * + * The two conflict kinds are the ones with real recovery flows behind them — the carry + * modal reopens with the conflicted file list, and the stash panel offers to keep the + * stash — so they have to arrive as their own classes. Everything else becomes a plain + * `Error`, which is what the components' `catch` blocks log and toast. + */ +function unwrap(result: VersionControlResult): T { + if (result.ok) { + return result.data + } + + const { failure } = result + + switch (failure.kind) { + case 'carry-conflict': + throw new SwitchBranchCarryConflictError(failure.conflictedFiles) + case 'stash-conflict': + throw new StashConflictError() + case 'merge-conflict': + throw new MergeConflictError(failure.conflictedFiles, failure.message) + case 'signed-out': + throw new Error('Not signed in to Autonomy Edge.') + case 'unreachable': + // Named as unreachable rather than as a failure of the operation: the branch was + // not "not created", it is unknown whether it was, and the user needs to know the + // difference before they try again. + throw new Error(`Could not reach Autonomy Edge. ${failure.message}`) + case 'http': + throw new Error(failure.message) + default: { + const exhaustive: never = failure + + throw new Error(`Unhandled version-control failure: ${JSON.stringify(exhaustive)}`) + } } +} + +/** + * Bind one IPC channel, guarding against a main process that predates it. + * + * A renderer bundle is not always paired with the main bundle it was built beside — a + * partial rebuild during development, or an app that updated one side, leaves the channel + * missing. Reading straight through would raise `... is not a function`, and an unhandled + * rejection inside a load effect takes down the whole workspace rather than the panel that + * asked. This has already happened once, on the cloud project list, so every channel goes + * through here and fails as an ordinary error the UI can report. + */ +function channel( + fn: ((...args: A) => Promise>) | undefined, + name: string, +): (...args: A) => Promise { + return async (...args: A) => { + if (typeof fn !== 'function') { + throw new Error(`Version control is unavailable in this build of the editor (${name} is missing).`) + } + + return unwrap(await fn(...args)) + } +} + +export function createEditorVersionControlAdapter(): VersionControlPort { + const { bridge } = window + + // Bound once, at construction: the channel set cannot change while the app runs, so + // the guard above is paid once per channel rather than on every call. + const listBranches = channel(bridge.edgeVcListBranches, 'edge-vc:list-branches') + const createBranch = channel(bridge.edgeVcCreateBranch, 'edge-vc:create-branch') + const deleteBranch = channel(bridge.edgeVcDeleteBranch, 'edge-vc:delete-branch') + const switchBranch = channel(bridge.edgeVcSwitchBranch, 'edge-vc:switch-branch') + const previewSwitchCarry = channel(bridge.edgeVcPreviewSwitchCarry, 'edge-vc:preview-switch-carry') + const listCommits = channel(bridge.edgeVcListCommits, 'edge-vc:list-commits') + const createCommit = channel(bridge.edgeVcCreateCommit, 'edge-vc:create-commit') + const getCommitFiles = channel(bridge.edgeVcGetCommitFiles, 'edge-vc:get-commit-files') + const restoreCommit = channel(bridge.edgeVcRestoreCommit, 'edge-vc:restore-commit') + const getChanges = channel(bridge.edgeVcGetChanges, 'edge-vc:get-changes') + const discardChanges = channel(bridge.edgeVcDiscardChanges, 'edge-vc:discard-changes') + const listStashes = channel(bridge.edgeVcListStashes, 'edge-vc:list-stashes') + const createStash = channel(bridge.edgeVcCreateStash, 'edge-vc:create-stash') + const applyStash = channel(bridge.edgeVcApplyStash, 'edge-vc:apply-stash') + const popStash = channel(bridge.edgeVcPopStash, 'edge-vc:pop-stash') + const dropStash = channel(bridge.edgeVcDropStash, 'edge-vc:drop-stash') + const branchDiffWithBase = channel(bridge.edgeVcBranchDiffWithBase, 'edge-vc:branch-diff-with-base') + const merge = channel(bridge.edgeVcMergeBranches, 'edge-vc:merge-branches') return { - listBranches: () => unsupported('listBranches'), - createBranch: () => unsupported('createBranch'), - deleteBranch: () => unsupported('deleteBranch'), - switchBranch: () => unsupported('switchBranch'), - previewSwitchCarry: () => unsupported('previewSwitchCarry'), - listCommits: () => unsupported('listCommits'), - createCommit: () => unsupported('createCommit'), - getCommitFiles: () => unsupported('getCommitFiles'), - restoreCommit: () => unsupported('restoreCommit'), - getChanges: () => unsupported('getChanges'), - discardChanges: () => unsupported('discardChanges'), - computeGraphicalDiff: () => unsupported('computeGraphicalDiff') as unknown as GraphicalDiffResult, - listStashes: () => unsupported('listStashes'), - createStash: () => unsupported('createStash'), - applyStash: () => unsupported('applyStash'), - popStash: () => unsupported('popStash'), - dropStash: () => unsupported('dropStash'), + listBranches: (projectId: string) => listBranches(projectId), + + createBranch: (projectId: string, name: string) => createBranch(projectId, name), + + deleteBranch: async (projectId: string, branchId: string) => { + await deleteBranch(projectId, branchId) + }, + + // Defaults to 'discard' here rather than relying on the main process, so the strategy + // the server is asked for is decided in one place and matches the web adapter's + // signature exactly. + switchBranch: (projectId: string, branchName: string, strategy: SwitchBranchStrategy = 'discard') => + switchBranch(projectId, branchName, strategy), + + previewSwitchCarry: (projectId: string, targetBranch: string) => previewSwitchCarry(projectId, targetBranch), + + listCommits: (projectId: string, options: ListCommitsOptions = {}) => listCommits(projectId, options), + + createCommit: (projectId: string, message: string, files?: string[], branch?: string): Promise => + createCommit(projectId, message, files, branch), + + getCommitFiles: (projectId: string, hash: string, branch?: string) => getCommitFiles(projectId, hash, branch), + + restoreCommit: (projectId: string, hash: string, branch?: string) => restoreCommit(projectId, hash, branch), + + // `branch` is accepted and dropped, exactly as the web adapter does: the backend's + // validation whitelist rejects the query param and computes pending changes against + // the worker's checked-out HEAD regardless, so forwarding it only earns a 400. + getChanges: (projectId: string, _branch?: string, includeContent?: boolean) => + getChanges(projectId, includeContent), + + discardChanges: async (projectId: string, files?: string[], _branch?: string) => { + await discardChanges(projectId, files) + }, + + listStashes: (projectId: string) => listStashes(projectId), + + createStash: (projectId: string, message?: string, files?: string[]) => createStash(projectId, message, files), + + applyStash: (projectId: string, ref: string) => applyStash(projectId, ref), + + popStash: (projectId: string, ref: string) => popStash(projectId, ref), + + dropStash: async (projectId: string, ref: string) => { + await dropStash(projectId, ref) + }, + + getBranchDiffWithBase: (projectId: string, source: string, target: string): Promise => + branchDiffWithBase(projectId, source, target), + + mergeBranches: (params: { + projectId: string + sourceBranch: string + targetBranch: string + commitMessage?: string + resolutions?: Record + }): Promise => merge(params), + + // Stays in the renderer: it is synchronous by contract, and it is pure computation + // over two file contents the caller already holds. Sending a whole LD program across + // IPC to compute a diff and sending the result back would be slower and would not + // make it any more correct. Shared module, so the desktop and the web produce the + // same diff from the same bytes. + computeGraphicalDiff: (originalContent: string, currentContent: string, filePath: string): GraphicalDiffResult => + computeGraphicalDiffImpl(originalContent, currentContent, filePath), } } diff --git a/src/middleware/editor-platform.ts b/src/middleware/editor-platform.ts index e01ebfe51..5a522ca67 100644 --- a/src/middleware/editor-platform.ts +++ b/src/middleware/editor-platform.ts @@ -17,6 +17,7 @@ import { createEditorAcceleratorAdapter } from './adapters/editor/accelerator-ad import { createEditorCompilerAdapter } from './adapters/editor/compiler-adapter' import { createEditorDebuggerAdapter } from './adapters/editor/debugger-adapter' import { createEditorDeviceAdapter } from './adapters/editor/device-adapter' +import { editorEdgeAccountPort } from './adapters/editor/edge-account-adapter' import { createEditorEsiAdapter } from './adapters/editor/esi-adapter' import { createEditorLibraryAdapter } from './adapters/editor/library-adapter' import { createEditorNavigationAdapter } from './adapters/editor/navigation-adapter' @@ -69,5 +70,11 @@ export const editorPorts: PlatformPorts = { navigation: createEditorNavigationAdapter(), library: createEditorLibraryAdapter(), stlibSource: createEditorStlibSourceAdapter(), + /** + * The Edge account. `EDITOR_CAPABILITIES` pairs it with `requiresEdgeAccount: false`, + * so the account control appears in the same slot as it does on the web while + * signing in stays optional here. + */ + edgeAccount: editorEdgeAccountPort, capabilities: { ...EDITOR_CAPABILITIES, isDevMode: process.env.NODE_ENV === 'development' }, } diff --git a/src/middleware/shared/ports/edge-account-port.ts b/src/middleware/shared/ports/edge-account-port.ts index 32c15f4bd..f7bb3ef59 100644 --- a/src/middleware/shared/ports/edge-account-port.ts +++ b/src/middleware/shared/ports/edge-account-port.ts @@ -12,9 +12,13 @@ * desktop editor, and a direct adapter import compiles that app against an API it * does not talk to. * - * OPTIONAL on `PlatformPorts`, like `ai` and `esi`: the desktop editor has no Edge - * account and sets `capabilities.hasEdgeAccount` to false. Gate on the capability, - * not on the port being present. + * OPTIONAL on `PlatformPorts`, like `ai` and `esi`: a platform may have no Edge + * account at all (the autonomy-node build talks to its own API). Gate on + * `capabilities.hasEdgeAccount`, not on the port being present. + * + * Both editors implement it, over different transports: the web build authenticates by + * the cookie Edge leaves on a shared parent domain, while the desktop holds its own + * tokens because its renderer is not on that domain. */ /** Mirrors Edge's `UserProfile`, narrowed to what the account UI renders. */ diff --git a/src/middleware/shared/ports/navigation-port.ts b/src/middleware/shared/ports/navigation-port.ts index ee51670d7..33e37012e 100644 --- a/src/middleware/shared/ports/navigation-port.ts +++ b/src/middleware/shared/ports/navigation-port.ts @@ -1,12 +1,13 @@ /** * NavigationPort — Abstracts in-app and external navigation. * - * Editor adapter: Falls back to `window.open` (new BrowserWindow) for - * secondary windows; `navigate` is a best-effort - * `window.location.href` fallback. The editor has no - * SPA router, but the routed features (merge, history) - * are gated behind `capabilities.hasVersionControl=false` - * and never reached in practice. + * Editor adapter: Intercepts the routed screens it can render in place — + * `/history` and `/merge` both become store state and are + * laid over the workspace. Any other in-app path is + * REFUSED rather than navigated to: the editor has no SPA + * router, and assigning `location.href` inside the Electron + * renderer reloads the shell, which used to close the open + * project. External URLs still open a window. * Web adapter: Delegates to TanStack Router's `router.navigate(...)` * for in-app navigation (preserves SPA state, no full * reload) and `window.open(url, '_blank')` for the diff --git a/src/middleware/shared/ports/platform-capabilities.ts b/src/middleware/shared/ports/platform-capabilities.ts index f85b05efc..bfe805a10 100644 --- a/src/middleware/shared/ports/platform-capabilities.ts +++ b/src/middleware/shared/ports/platform-capabilities.ts @@ -34,6 +34,20 @@ export interface PlatformCapabilities { * work. Gate the account UI on THIS flag, never on `hasAuthentication`. */ hasEdgeAccount: boolean + /** + * Whether the build is UNUSABLE without an Edge account. + * + * Distinct from `hasEdgeAccount`, and the distinction is load-bearing. The web + * editor reaches a project only through Edge's API, so a visitor who is not signed + * in has nothing to look at and the sign-in dialog opens on its own. The desktop + * editor opens local projects from disk and works offline: an account is how you + * reach CLOUD projects, and forcing a dialog on someone editing a local file would + * block an editor that needs nothing from Edge at all. + * + * Both builds show the same account control in the same place. This only decides + * whether the sign-in dialog opens by itself or when the user asks for it. + */ + requiresEdgeAccount: boolean // --- Device & Hardware --- @@ -64,6 +78,16 @@ export interface PlatformCapabilities { /** True if the app supports version control (branches, commits, change tracking). */ hasVersionControl: boolean + /** + * Whether this build can show the branch merge screen. + * + * Both have it today: the screen is shared, and the desktop reaches it through its + * navigation adapter rather than a route. The flag stays because the fact it describes + * is its own — a build can have version control and no merge screen, which the desktop + * briefly did, and the entry then has to be withheld rather than left pointing at + * nothing. Collapsing it into `hasVersionControl` would remove the way to say that. + */ + hasBranchMerge: boolean /** True if the app supports the "About" dialog. */ hasAboutDialog: boolean @@ -140,8 +164,10 @@ export const EDITOR_CAPABILITIES: PlatformCapabilities = { isNativeApplication: true, hasNativeFileDialogs: true, hasAuthentication: false, - // Desktop editor works against the local filesystem, with no Edge account. - hasEdgeAccount: false, + // The desktop editor has an Edge account, for cloud projects... + hasEdgeAccount: true, + // ...but never demands one: local projects and offline work need no sign-in. + requiresEdgeAccount: false, hasLocalSerialPorts: true, hasOrchestratorDevices: false, hasWebRTC: false, @@ -149,7 +175,11 @@ export const EDITOR_CAPABILITIES: PlatformCapabilities = { hasLocalFilesystem: true, hasProjectExport: true, hasProjectImport: true, - hasVersionControl: false, + // On for cloud projects only, which the shared gate enforces by asking whether the + // open project lives on Edge. The repository sits beside the project on the server, + // so a project opened from disk has no history to show — see `isRemoteProjectPath`. + hasVersionControl: true, + hasBranchMerge: true, hasAboutDialog: true, hasPythonLSP: true, // Worker wired via src/frontend/services/st-lsp/boot.ts, started @@ -173,6 +203,9 @@ export const WEB_CAPABILITIES: PlatformCapabilities = { hasAuthentication: true, // Default for the web build; the autonomy-node build turns this off via env. hasEdgeAccount: true, + // The web editor reaches a project only through Edge's API, so without a session + // there is nothing to show and the dialog opens on its own. + requiresEdgeAccount: true, hasLocalSerialPorts: false, hasOrchestratorDevices: true, hasWebRTC: true, @@ -183,6 +216,7 @@ export const WEB_CAPABILITIES: PlatformCapabilities = { hasProjectExport: true, hasProjectImport: true, hasVersionControl: true, + hasBranchMerge: true, hasAboutDialog: true, // `monaco-pyright-lsp` ships its own ESM worker via // `new Worker(new URL('./worker.js', import.meta.url))` which Vite diff --git a/src/middleware/shared/ports/project-port.ts b/src/middleware/shared/ports/project-port.ts index 303e15d53..e59e8850c 100644 --- a/src/middleware/shared/ports/project-port.ts +++ b/src/middleware/shared/ports/project-port.ts @@ -208,6 +208,19 @@ export interface RawProjectFiles { * "not pending" case (normal project, has `project.json`). */ pendingPlcopenSource?: string + /** + * The file bytes exactly as the source handed them over, keyed by relative path. + * + * Present for a reader that has a notion of "as loaded" separate from the parsed + * result — a cloud project, whose bytes came off the wire. Absent for the filesystem + * reader, where the files on disk ARE the loaded state, and the sync point treats + * absent the same as nothing to echo. + * + * The save flow uses it to upload unedited files unchanged instead of re-serialising + * them, which is what keeps a save from rewriting every file in the editor's own + * formatting and reporting the whole project as modified. + */ + rawLoadedFiles?: Record } /** * `status` carries the HTTP status when the platform had one, for the same @@ -218,7 +231,101 @@ export interface RawProjectFiles { error?: { title: string; description: string; status?: number } } +/** + * The outcome of asking for the account's cloud projects. + * + * NOT just an array, and the distinction is the whole point: an empty list would + * collapse "you are not signed in", "you have no projects yet" and "we could not + * reach Edge" into one value, and the three call for completely different words on + * screen. Telling someone to sign in when they are already signed in and simply + * offline is the same class of bug `EdgeUserRead.unknown` exists to prevent. + */ +export type CloudProjectsResult = + | { status: 'ok'; projects: CloudProjectSummary[] } + /** The server answered, and there is no usable session. */ + | { status: 'signed-out' } + /** The question could not be asked — offline, DNS, a dropped connection. */ + | { status: 'unreachable' } + /** This build has no channel for cloud projects at all. */ + | { status: 'unavailable' } + +/** A project on the user's Autonomy Edge account, as a list needs to show it. */ +export interface CloudProjectSummary { + id: string + name: string + /** IEC language slug, e.g. `st` / `ld`. Absent on projects that never set one. */ + language?: string | null + /** ISO timestamp of the last change, which is what "recent" is ordered by. */ + updatedAt: string +} + +// --------------------------------------------------------------------------- +// Publishing a local project to Autonomy Edge +// --------------------------------------------------------------------------- + +/** A destination the user can publish into. Flattened, with `depth` to read as a tree. */ +export interface CloudFolder { + id: string + /** Display-ready. The account's root folder is named after the user id on the wire. */ + name: string + depth: number +} + +export type CloudFoldersResult = + | { status: 'ok'; folders: CloudFolder[] } + | { status: 'signed-out' } + | { status: 'unreachable' } + +/** + * Why publishing did not happen, as data. + * + * Each case exists because the user's next move differs. "This folder is not an OpenPLC + * project" is a different problem from "your project is too big" and from "the connection + * dropped, so check Edge before trying again" — and the last one matters most: the upload + * is not idempotent, so an unanswered request may well have created the project. + */ +export type UploadProjectFailure = + | { reason: 'no-manifest' } + | { reason: 'empty' } + | { reason: 'too-many-files'; count: number } + | { reason: 'too-deep' } + | { reason: 'file-too-large'; relativePath: string; bytes: number } + | { reason: 'too-large'; bytes: number } + | { reason: 'unreadable'; message: string } + | { reason: 'signed-out' } + | { reason: 'unreachable'; message: string } + | { reason: 'rejected'; status: number; message: string } + +export type UploadProjectResult = + | { status: 'ok'; projectId: string | null; uploadedFiles: number } + | { status: 'failed'; failure: UploadProjectFailure } + +export interface UploadProjectParams { + /** Absolute path of the project directory on this machine. */ + projectPath: string + parentFolderId: string + /** Overrides the name inside `project.json`. */ + projectName?: string + visibility: 'public' | 'private' +} + export interface ProjectPort { + /** + * Folders on Autonomy Edge the signed-in account can publish into. + * + * Optional: only a platform that can hold local projects AND reach Edge has anything to + * publish. Absent everywhere else, including the web build, where a project is already + * on Edge by definition. + */ + listCloudFolders?(): Promise + + /** + * Archive a project on this machine and import it into Edge. + * + * Optional for the same reason as `listCloudFolders`. + */ + uploadProjectToCloud?(params: UploadProjectParams): Promise + /** Create a new project. */ createProject(params: CreateProjectParams): Promise @@ -276,6 +383,23 @@ export interface ProjectPort { /** Get list of recently opened projects. */ getRecentProjects(): Promise + /** + * The most recently changed projects on the signed-in Autonomy Edge account. + * + * OPTIONAL, because not every platform lists cloud projects. The desktop editor does: + * it is the only place that shows local and cloud side by side, and reaching one from + * the other is the point. The web editor does not — it is always opened on a specific + * project, and Edge's own SPA is where a person browses them. + * + * Ordering is the server's (`updatedAt` descending). Sorting a truncated page here + * would be wrong: the five newest of ten fetched rows are not the five newest overall. + * + * Resolves a DISCRIMINATED result rather than a list, so the caller can tell being + * signed out from having no projects from being offline. Signing in is optional, so + * none of those is an error — but they are not the same thing to say. + */ + listRecentCloudProjects?(limit: number): Promise + /** * Drop a project entry from the recent-projects list without * touching disk. Used by the start-screen 3-dot menu's "Remove diff --git a/src/middleware/shared/ports/types.ts b/src/middleware/shared/ports/types.ts index 191053bfe..9cd0e892a 100644 --- a/src/middleware/shared/ports/types.ts +++ b/src/middleware/shared/ports/types.ts @@ -524,6 +524,33 @@ export function isLibraryProject(meta: { type: 'plc-project' | 'plc-library' } | return meta?.type === 'plc-library' } +/** + * True when a project identifier names a project held on Autonomy Edge rather + * than a file on this machine. + * + * `meta.path` carries both kinds. On the web it is always an Edge project id. + * On the desktop it is an Edge project id for a cloud project and an absolute + * path for one on disk, so an absolute path is the only thing that separates + * them — which is why this tests for one instead of guessing at id formats. A + * project id is opaque and its shape is the server's business; "starts with a + * slash or a drive letter" is a fact about filesystems that will not change. + * + * Version control is the caller this exists for: the git repository lives + * beside the project on the server, so a project sitting on disk has no + * history to show and no branches to switch. + */ +export function isRemoteProjectPath(identifier: string): boolean { + if (identifier.length === 0) { + return false + } + + const isPosixAbsolute = identifier.startsWith('/') + // `C:\...` or `C:/...`, and `\\server\share` for a UNC path. + const isWindowsAbsolute = /^[A-Za-z]:[\\/]/.test(identifier) || identifier.startsWith('\\\\') + + return !isPosixAbsolute && !isWindowsAbsolute +} + /** * Per-project-type capability matrix. Drives every UI affordance * that depends on what kind of project is open: project tree diff --git a/src/middleware/shared/ports/version-control-port.ts b/src/middleware/shared/ports/version-control-port.ts index 3b77eb8b4..85486220e 100644 --- a/src/middleware/shared/ports/version-control-port.ts +++ b/src/middleware/shared/ports/version-control-port.ts @@ -97,6 +97,106 @@ export class StashConflictError extends Error { export type SwitchBranchStrategy = 'discard' | 'carry' +// --------------------------------------------------------------------------- +// Merging one branch into another +// --------------------------------------------------------------------------- + +/** A file as it stands in one branch's snapshot. */ +export interface BranchDiffFile { + path: string + content: string + type: 'file' | 'directory' +} + +export interface BranchCommitInfo { + hash: string + shortHash: string + message: string + author: string + authorEmail: string + timestamp: string + branch: string + parentHash: string | null +} + +/** One side of a three-way comparison: the branch, its tip commit, and its files. */ +export interface BranchSnapshot { + branch: string + commit: BranchCommitInfo + files: BranchDiffFile[] +} + +/** + * The three-way view a merge is decided from. + * + * `base` is the common ancestor and may be null when the two branches share no history — + * the screen then has nothing to three-way against and falls back to comparing the tips. + * `conflicts` is the server's own prediction, so the UI can ask for resolutions before + * attempting the merge rather than after being refused. + */ +export interface BranchDiffWithBase { + source: BranchSnapshot + target: BranchSnapshot + base: BranchSnapshot | null + conflicts: string[] +} + +export interface MergeResult { + message: string + mergeCommit: BranchCommitInfo + sourceBranch: string + targetBranch: string +} + +/** + * Raised when the merge cannot proceed without a decision per conflicting file. + * + * The server answers 409 with the list, and it does so whether or not resolutions were + * sent: an incomplete set is refused the same way an absent one is. Typed, because the + * screen's whole recovery flow is "show me those files and let me choose" — and because a + * plain message would leave it parsing prose to find out which files to ask about. + */ +export class MergeConflictError extends Error { + readonly conflictedFiles: string[] + + constructor(conflictedFiles: string[], message = 'The merge has conflicts that need resolving') { + super(message) + this.name = 'MergeConflictError' + this.conflictedFiles = conflictedFiles + } +} + +/** + * How a version-control operation can fail, as data rather than as an exception. + * + * The desktop runs these operations in its main process and reports the outcome across + * IPC, which structure-clones the value: an `Error` subclass sent that way arrives with + * its prototype gone, so `instanceof` answers false and the two conflict flows below stop + * working. Describing the failure instead, and rebuilding the error on the far side, is + * what keeps the desktop's components identical to the web's. + * + * Each case is kept apart because each needs a different thing from the user: + * + * - `signed-out` — no session to spend; sign in. Nothing is wrong with the project. + * - `unreachable` — the server never answered, so it is UNKNOWN whether the operation + * ran. Reporting this as a refusal would be a lie in the more dangerous direction. + * - `carry-conflict` — the edits cannot be carried to the target branch. Carries the + * conflicted paths, which is what the switch modal reopens with. + * - `stash-conflict` — the stash will not apply cleanly. The stash is kept. + * - `http` — anything else, with the status, so a 403 on a read-only project reads + * differently from a 500. + */ +export type VersionControlFailure = + | { kind: 'signed-out' } + | { kind: 'unreachable'; message: string } + | { kind: 'carry-conflict'; conflictedFiles: string[] } + | { kind: 'stash-conflict' } + | { kind: 'merge-conflict'; conflictedFiles: string[]; message: string } + | { kind: 'http'; status: number; message: string } + +/** A version-control outcome in transportable form. See {@link VersionControlFailure}. */ +export type VersionControlResult = { ok: true; data: T } | { ok: false; failure: VersionControlFailure } + /** * Thrown by `switchBranch` when called with `strategy: 'carry'` and the * server detects conflicts that would block the carry. The project state on @@ -124,6 +224,29 @@ export interface ListCommitsOptions { } export interface VersionControlPort { + /** + * Three-way comparison between two branches and their common ancestor, with the server's + * prediction of which files would conflict. + * + * Optional so a platform that cannot show the merge screen is not forced to implement + * it. Gated by `capabilities.hasBranchMerge`. + */ + getBranchDiffWithBase?(projectId: string, source: string, target: string): Promise + + /** + * Merge `sourceBranch` into `targetBranch`. + * + * `resolutions` maps a conflicting file's relative path to the content that should win. + * Omit it on a merge with no conflicts; supply every conflicting file when there are, or + * the call rejects with {@link MergeConflictError} carrying the ones still outstanding. + */ + mergeBranches?(params: { + projectId: string + sourceBranch: string + targetBranch: string + commitMessage?: string + resolutions?: Record + }): Promise /** List all branches for a project. */ listBranches(projectId: string): Promise<{ branches: Branch[] }> diff --git a/src/middleware/shared/providers/types.ts b/src/middleware/shared/providers/types.ts index df020acc3..d95935bb5 100644 --- a/src/middleware/shared/providers/types.ts +++ b/src/middleware/shared/providers/types.ts @@ -44,9 +44,12 @@ export interface PlatformPorts { esi?: EsiPort ai?: AIPort /** - * Optional — the web build only. The desktop editor has no Edge account and - * sets `capabilities.hasEdgeAccount` false; gate on that capability rather than - * on this being present. + * Optional, because a platform may have no Edge account at all — the autonomy-node + * build talks to its own API, where Edge's account endpoints do not exist. Gate on + * `capabilities.hasEdgeAccount` rather than on this being present. + * + * Both the web editor and the desktop editor supply it. They differ in + * `requiresEdgeAccount`, not in whether an account exists. */ edgeAccount?: EdgeAccountPort /**