diff --git a/backend/src/__tests__/stack-compose-mtime.test.ts b/backend/src/__tests__/stack-compose-mtime.test.ts new file mode 100644 index 00000000..0623d0a8 --- /dev/null +++ b/backend/src/__tests__/stack-compose-mtime.test.ts @@ -0,0 +1,236 @@ +/** + * Integration tests for optimistic concurrency on PUT /api/stacks/:name and + * PUT /api/stacks/:name/env. + * + * GET returns the file content with an `ETag` header carrying the mtimeMs. + * The frontend echoes that as `If-Match` on save. When the file on disk has + * mutated in the interim, the server returns 412 with the current content + * and mtime so the caller can show a "file changed" recovery sheet. + * + * These tests use real fs ops against a temp COMPOSE_DIR rather than mocks + * because the contract is specifically about mtime semantics. + */ +import { describe, it, expect, beforeAll, afterAll, beforeEach } from 'vitest'; +import request from 'supertest'; +import fs from 'fs'; +import path from 'path'; +import { setupTestDb, cleanupTestDb, loginAsTestAdmin } from './helpers/setupTestDb'; + +let tmpDir: string; +let composeDir: string; +let app: import('express').Express; +let authCookie: string; + +const STACK = 'web'; + +function seedStack(stackName: string, content: string): string { + const stackDir = path.join(composeDir, stackName); + fs.mkdirSync(stackDir, { recursive: true }); + const filePath = path.join(stackDir, 'compose.yaml'); + fs.writeFileSync(filePath, content, 'utf-8'); + return filePath; +} + +function seedEnv(stackName: string, content: string): string { + const stackDir = path.join(composeDir, stackName); + fs.mkdirSync(stackDir, { recursive: true }); + const envPath = path.join(stackDir, '.env'); + fs.writeFileSync(envPath, content, 'utf-8'); + return envPath; +} + +function parseEtag(etag: string | undefined): number | null { + if (!etag) return null; + const m = etag.match(/(?:W\/)?"(\d+)"/); + return m ? Number(m[1]) : null; +} + +beforeAll(async () => { + tmpDir = await setupTestDb(); + composeDir = process.env.COMPOSE_DIR as string; + ({ app } = await import('../index')); + authCookie = await loginAsTestAdmin(app); +}); + +afterAll(() => { + cleanupTestDb(tmpDir); +}); + +beforeEach(() => { + const stackDir = path.join(composeDir, STACK); + if (fs.existsSync(stackDir)) { + fs.rmSync(stackDir, { recursive: true, force: true }); + } +}); + +describe('GET /api/stacks/:stackName emits ETag with mtime', () => { + it('responds 200 with content body and a W/"" ETag header', async () => { + seedStack(STACK, 'services:\n web:\n image: nginx\n'); + + const res = await request(app) + .get(`/api/stacks/${STACK}`) + .set('Cookie', authCookie); + + expect(res.status).toBe(200); + expect(res.text).toContain('image: nginx'); + const etag = res.headers.etag; + expect(etag).toMatch(/^W\/"\d+"$/); + expect(parseEtag(etag)).toBeGreaterThan(0); + }); +}); + +describe('PUT /api/stacks/:stackName optimistic concurrency', () => { + it('writes successfully when If-Match matches current mtime', async () => { + seedStack(STACK, 'original'); + const getRes = await request(app).get(`/api/stacks/${STACK}`).set('Cookie', authCookie); + const etag = getRes.headers.etag as string; + + const putRes = await request(app) + .put(`/api/stacks/${STACK}`) + .set('Cookie', authCookie) + .set('If-Match', etag) + .send({ content: 'updated' }); + + expect(putRes.status).toBe(200); + expect(putRes.headers.etag).toMatch(/^W\/"\d+"$/); + expect(typeof putRes.body.mtimeMs).toBe('number'); + const filePath = path.join(composeDir, STACK, 'compose.yaml'); + expect(fs.readFileSync(filePath, 'utf-8')).toBe('updated'); + }); + + it('returns 412 with stack_file_changed and the current content on If-Match mismatch', async () => { + seedStack(STACK, 'original'); + const getRes = await request(app).get(`/api/stacks/${STACK}`).set('Cookie', authCookie); + const etag = getRes.headers.etag as string; + + const filePath = path.join(composeDir, STACK, 'compose.yaml'); + fs.writeFileSync(filePath, 'changed-by-other-tab', 'utf-8'); + const future = Date.now() + 5_000; + fs.utimesSync(filePath, future / 1000, future / 1000); + + const putRes = await request(app) + .put(`/api/stacks/${STACK}`) + .set('Cookie', authCookie) + .set('If-Match', etag) + .send({ content: 'my-overwrite' }); + + expect(putRes.status).toBe(412); + expect(putRes.body).toMatchObject({ + code: 'stack_file_changed', + currentContent: 'changed-by-other-tab', + }); + expect(typeof putRes.body.currentMtimeMs).toBe('number'); + expect(putRes.headers.etag).toMatch(/^W\/"\d+"$/); + expect(fs.readFileSync(filePath, 'utf-8')).toBe('changed-by-other-tab'); + }); + + it('writes through when If-Match is absent', async () => { + seedStack(STACK, 'original'); + + const putRes = await request(app) + .put(`/api/stacks/${STACK}`) + .set('Cookie', authCookie) + .send({ content: 'forced' }); + + expect(putRes.status).toBe(200); + const filePath = path.join(composeDir, STACK, 'compose.yaml'); + expect(fs.readFileSync(filePath, 'utf-8')).toBe('forced'); + }); + + it('writes through when the file does not exist yet (first save creates compose.yaml)', async () => { + const stackDir = path.join(composeDir, STACK); + fs.mkdirSync(stackDir, { recursive: true }); + fs.writeFileSync(path.join(stackDir, 'docker-compose.yaml'), 'legacy', 'utf-8'); + + const putRes = await request(app) + .put(`/api/stacks/${STACK}`) + .set('Cookie', authCookie) + .set('If-Match', 'W/"1234"') + .send({ content: 'fresh' }); + + expect(putRes.status).toBe(200); + expect(fs.readFileSync(path.join(stackDir, 'compose.yaml'), 'utf-8')).toBe('fresh'); + }); + + it('detects a near-boundary mtime change (Math.floor precision)', async () => { + seedStack(STACK, 'original'); + const getRes = await request(app).get(`/api/stacks/${STACK}`).set('Cookie', authCookie); + const etag = getRes.headers.etag as string; + const filePath = path.join(composeDir, STACK, 'compose.yaml'); + const originalStat = fs.statSync(filePath); + + // Bump the mtime by exactly one full second so Math.floor(mtimeMs) is + // guaranteed to differ even on filesystems that round to whole seconds. + fs.writeFileSync(filePath, 'changed-just-after', 'utf-8'); + const bumped = (originalStat.mtimeMs + 1000) / 1000; + fs.utimesSync(filePath, bumped, bumped); + + const putRes = await request(app) + .put(`/api/stacks/${STACK}`) + .set('Cookie', authCookie) + .set('If-Match', etag) + .send({ content: 'my-edit' }); + + expect(putRes.status).toBe(412); + expect(putRes.body.code).toBe('stack_file_changed'); + }); + + it('ignores malformed If-Match headers and writes through', async () => { + seedStack(STACK, 'original'); + + const putRes = await request(app) + .put(`/api/stacks/${STACK}`) + .set('Cookie', authCookie) + .set('If-Match', 'not-a-valid-etag') + .send({ content: 'forced' }); + + expect(putRes.status).toBe(200); + }); +}); + +describe('PUT /api/stacks/:stackName/env optimistic concurrency', () => { + it('writes successfully when If-Match matches', async () => { + seedStack(STACK, 'services: {}'); + const envPath = seedEnv(STACK, 'FOO=1'); + + const getRes = await request(app) + .get(`/api/stacks/${STACK}/env?file=${encodeURIComponent(envPath)}`) + .set('Cookie', authCookie); + const etag = getRes.headers.etag as string; + expect(etag).toMatch(/^W\/"\d+"$/); + + const putRes = await request(app) + .put(`/api/stacks/${STACK}/env?file=${encodeURIComponent(envPath)}`) + .set('Cookie', authCookie) + .set('If-Match', etag) + .send({ content: 'FOO=2' }); + + expect(putRes.status).toBe(200); + expect(fs.readFileSync(envPath, 'utf-8')).toBe('FOO=2'); + }); + + it('returns 412 on env-file mtime mismatch', async () => { + seedStack(STACK, 'services: {}'); + const envPath = seedEnv(STACK, 'FOO=1'); + + const getRes = await request(app) + .get(`/api/stacks/${STACK}/env?file=${encodeURIComponent(envPath)}`) + .set('Cookie', authCookie); + const etag = getRes.headers.etag as string; + + fs.writeFileSync(envPath, 'FOO=bumped', 'utf-8'); + const future = Date.now() + 5_000; + fs.utimesSync(envPath, future / 1000, future / 1000); + + const putRes = await request(app) + .put(`/api/stacks/${STACK}/env?file=${encodeURIComponent(envPath)}`) + .set('Cookie', authCookie) + .set('If-Match', etag) + .send({ content: 'FOO=my-overwrite' }); + + expect(putRes.status).toBe(412); + expect(putRes.body.code).toBe('stack_file_changed'); + expect(putRes.body.currentContent).toBe('FOO=bumped'); + expect(fs.readFileSync(envPath, 'utf-8')).toBe('FOO=bumped'); + }); +}); diff --git a/backend/src/routes/stacks.ts b/backend/src/routes/stacks.ts index 884fee69..865ab15f 100644 --- a/backend/src/routes/stacks.ts +++ b/backend/src/routes/stacks.ts @@ -79,6 +79,18 @@ function releaseStackOpLock(req: Request, stackName: string): void { StackOpLockService.getInstance().release(req.nodeId, stackName); } +function stackFileEtag(mtimeMs: number): string { + return `W/"${Math.floor(mtimeMs)}"`; +} + +function parseIfMatchMtime(raw: string | undefined): number | null { + if (!raw) return null; + const m = /(?:W\/)?"(\d+)"/.exec(raw); + if (!m) return null; + const value = Number(m[1]); + return Number.isFinite(value) ? value : null; +} + async function requireStackExists(nodeId: number, stackName: string, res: Response): Promise { if (!isValidStackName(stackName)) { res.status(400).json({ error: 'Invalid stack name' }); @@ -280,7 +292,8 @@ stacksRouter.put('/:stackName/auto-update', (req: Request, res: Response): void stacksRouter.get('/:stackName', async (req: Request, res: Response) => { try { const stackName = req.params.stackName as string; - const content = await FileSystemService.getInstance(req.nodeId).getStackContent(stackName); + const { content, mtimeMs } = await FileSystemService.getInstance(req.nodeId).getStackContentWithMtime(stackName); + res.setHeader('ETag', stackFileEtag(mtimeMs)); res.send(content); } catch (error) { res.status(500).json({ error: 'Failed to read stack' }); @@ -297,10 +310,22 @@ stacksRouter.put('/:stackName', async (req: Request, res: Response) => { return res.status(400).json({ error: 'Content must be a string' }); } if (isDebugEnabled()) console.debug(`[Stacks:debug] Save starting`, { stackName: sanitizeForLog(stackName), bytes: content.length, nodeId: req.nodeId }); - await FileSystemService.getInstance(req.nodeId).saveStackContent(stackName, content); + const expectedMtimeMs = parseIfMatchMtime(req.header('if-match')); + const result = await FileSystemService.getInstance(req.nodeId) + .saveStackContentIfUnchanged(stackName, content, expectedMtimeMs); + if (!result.ok) { + res.setHeader('ETag', stackFileEtag(result.currentMtimeMs)); + return res.status(412).json({ + error: `${stackName}'s compose file changed since you opened it.`, + code: 'stack_file_changed', + currentMtimeMs: result.currentMtimeMs, + currentContent: result.currentContent, + }); + } invalidateNodeCaches(req.nodeId); console.log(`[Stacks] Compose file saved: ${sanitizeForLog(stackName)}`); - res.json({ message: 'Stack saved successfully' }); + res.setHeader('ETag', stackFileEtag(result.mtimeMs)); + res.json({ message: 'Stack saved successfully', mtimeMs: result.mtimeMs }); } catch (error) { console.error('Failed to save stack:', sanitizeForLog(getErrorMessage(error, 'unknown'))); res.status(500).json({ error: 'Failed to save stack' }); @@ -363,6 +388,8 @@ stacksRouter.get('/:stackName/env', async (req: Request, res: Response) => { try { const content = await fsService.readFile(envPath, 'utf-8'); + const mtimeMs = await fsService.statMtime(envPath); + if (mtimeMs !== null) res.setHeader('ETag', stackFileEtag(mtimeMs)); res.setHeader('X-Env-Exists', 'true'); return res.send(content); } catch (e: unknown) { @@ -405,11 +432,22 @@ stacksRouter.put('/:stackName/env', async (req: Request, res: Response) => { } const fsService = FileSystemService.getInstance(req.nodeId); - await fsService.writeFile(envPath, content, 'utf-8'); + const expectedMtimeMs = parseIfMatchMtime(req.header('if-match')); + const result = await fsService.writeFileIfUnchanged(envPath, content, expectedMtimeMs); + if (!result.ok) { + res.setHeader('ETag', stackFileEtag(result.currentMtimeMs)); + return res.status(412).json({ + error: `${stackName}'s env file changed since you opened it.`, + code: 'stack_file_changed', + currentMtimeMs: result.currentMtimeMs, + currentContent: result.currentContent, + }); + } invalidateNodeCaches(req.nodeId); const envFileName = path.basename(envPath); console.log(`[Stacks] Env file saved: ${sanitizeForLog(stackName)}/${sanitizeForLog(envFileName)}`); - res.json({ message: 'Env file saved successfully' }); + res.setHeader('ETag', stackFileEtag(result.mtimeMs)); + res.json({ message: 'Env file saved successfully', mtimeMs: result.mtimeMs }); } catch (error) { console.error('[Stacks] Failed to save env file:', error); res.status(500).json({ error: 'Failed to save env file' }); diff --git a/backend/src/services/FileSystemService.ts b/backend/src/services/FileSystemService.ts index c2b16b50..6dbc346f 100644 --- a/backend/src/services/FileSystemService.ts +++ b/backend/src/services/FileSystemService.ts @@ -156,6 +156,32 @@ export class FileSystemService { } } + /** + * Read the resolved compose file along with its mtimeMs, which the route + * layer surfaces as an ETag for optimistic-concurrency on PUT. The stat + * and read share a single file descriptor so they observe the same inode + * state, even if the file is replaced (rename) between the two calls. + */ + async getStackContentWithMtime(stackName: string): Promise<{ content: string; mtimeMs: number }> { + const untrustedFilePath = await this.getComposeFilePath(stackName); + // Canonical js/path-injection barrier: resolve against a known-safe root + // then check the result is contained in that root. The form mirrors + // CodeQL's documented sanitizer exactly so taint flow recognizes it. + const baseResolved = path.resolve(this.baseDir); + const safePath = path.resolve(baseResolved, untrustedFilePath); + if (!safePath.startsWith(baseResolved + path.sep)) { + throw Object.assign(new Error('Path escapes compose directory'), { code: 'INVALID_PATH' }); + } + const fh = await fsPromises.open(safePath, 'r'); + try { + const stat = await fh.stat(); + const content = await fh.readFile('utf-8'); + return { content, mtimeMs: stat.mtimeMs }; + } finally { + await fh.close(); + } + } + async saveStackContent(stackName: string, content: string): Promise { const stackDir = this.resolveStackDir(stackName); const filePath = path.join(stackDir, 'compose.yaml'); @@ -167,6 +193,112 @@ export class FileSystemService { } } + /** + * Optimistic-concurrency write: if `expectedMtimeMs` is provided, stat the + * write target first and return `{ok: false}` with the current content and + * mtime when they don't match. The route maps that to 412. Mtime comparison + * uses Math.floor to absorb sub-millisecond jitter from different file + * systems / Node versions. + * + * If the file doesn't exist yet, the write proceeds (no mtime to compare). + * Returns the new mtimeMs so the route can emit a fresh ETag. + */ + async saveStackContentIfUnchanged( + stackName: string, + content: string, + expectedMtimeMs: number | null, + ): Promise< + | { ok: true; mtimeMs: number } + | { ok: false; currentMtimeMs: number; currentContent: string } + > { + const stackDir = this.resolveStackDir(stackName); + const untrustedFilePath = path.join(stackDir, 'compose.yaml'); + // Canonical js/path-injection barrier: path.resolve(SAFE_ROOT, untrusted) + // followed by a single startsWith check, both inline with the sink. + const baseResolved = path.resolve(this.baseDir); + const safePath = path.resolve(baseResolved, untrustedFilePath); + if (!safePath.startsWith(baseResolved + path.sep)) { + throw Object.assign(new Error('Path escapes compose directory'), { code: 'INVALID_PATH' }); + } + + if (expectedMtimeMs !== null) { + let fh: import('fs/promises').FileHandle | null = null; + try { + fh = await fsPromises.open(safePath, 'r'); + const stat = await fh.stat(); + if (Math.floor(stat.mtimeMs) !== Math.floor(expectedMtimeMs)) { + const currentContent = await fh.readFile('utf-8'); + return { ok: false, currentMtimeMs: stat.mtimeMs, currentContent }; + } + } catch (err) { + if ((err as NodeJS.ErrnoException).code !== 'ENOENT') throw err; + // File doesn't exist yet, treat as fresh write. + } finally { + if (fh) await fh.close(); + } + } + + await fsPromises.writeFile(safePath, content, 'utf-8'); + const newStat = await fsPromises.stat(safePath); + return { ok: true, mtimeMs: newStat.mtimeMs }; + } + + /** + * Optimistic-concurrency write for arbitrary paths under the stack dir + * (used for .env files; the path was already validated by the caller). + */ + async writeFileIfUnchanged( + untrustedTargetPath: string, + content: string, + expectedMtimeMs: number | null, + ): Promise< + | { ok: true; mtimeMs: number } + | { ok: false; currentMtimeMs: number; currentContent: string } + > { + // Canonical js/path-injection barrier: path.resolve(SAFE_ROOT, untrusted) + // followed by a single startsWith check, both inline with the sink. + const baseResolved = path.resolve(this.baseDir); + const safePath = path.resolve(baseResolved, untrustedTargetPath); + if (!safePath.startsWith(baseResolved + path.sep)) { + throw Object.assign(new Error('Path escapes compose directory'), { code: 'INVALID_PATH' }); + } + + if (expectedMtimeMs !== null) { + let fh: import('fs/promises').FileHandle | null = null; + try { + fh = await fsPromises.open(safePath, 'r'); + const stat = await fh.stat(); + if (Math.floor(stat.mtimeMs) !== Math.floor(expectedMtimeMs)) { + const currentContent = await fh.readFile('utf-8'); + return { ok: false, currentMtimeMs: stat.mtimeMs, currentContent }; + } + } catch (err) { + if ((err as NodeJS.ErrnoException).code !== 'ENOENT') throw err; + } finally { + if (fh) await fh.close(); + } + } + + await fsPromises.writeFile(safePath, content, 'utf-8'); + const newStat = await fsPromises.stat(safePath); + return { ok: true, mtimeMs: newStat.mtimeMs }; + } + + async statMtime(untrustedTargetPath: string): Promise { + const baseResolved = path.resolve(this.baseDir); + const safePath = path.resolve(baseResolved, untrustedTargetPath); + if (!safePath.startsWith(baseResolved + path.sep)) { + throw Object.assign(new Error('Path escapes compose directory'), { code: 'INVALID_PATH' }); + } + try { + const stat = await fsPromises.stat(safePath); + return stat.mtimeMs; + } catch (err) { + if ((err as NodeJS.ErrnoException).code === 'ENOENT') return null; + throw err; + } + } + async envExists(stackName: string): Promise { const stackDir = this.resolveStackDir(stackName); try { diff --git a/frontend/src/components/EditorLayout/hooks/useEditorViewState.ts b/frontend/src/components/EditorLayout/hooks/useEditorViewState.ts index 9a5bee5e..81251fc2 100644 --- a/frontend/src/components/EditorLayout/hooks/useEditorViewState.ts +++ b/frontend/src/components/EditorLayout/hooks/useEditorViewState.ts @@ -31,8 +31,10 @@ export function useEditorViewState() { const [content, setContent] = useState(''); const [originalContent, setOriginalContent] = useState(''); + const [composeEtag, setComposeEtag] = useState(null); const [envContent, setEnvContent] = useState(''); const [originalEnvContent, setOriginalEnvContent] = useState(''); + const [envEtag, setEnvEtag] = useState(null); const [envExists, setEnvExists] = useState(false); const [envFiles, setEnvFiles] = useState([]); const [selectedEnvFile, setSelectedEnvFile] = useState(''); @@ -57,8 +59,10 @@ export function useEditorViewState() { copiedDigestTimerRef, content, setContent, originalContent, setOriginalContent, + composeEtag, setComposeEtag, envContent, setEnvContent, originalEnvContent, setOriginalEnvContent, + envEtag, setEnvEtag, envExists, setEnvExists, envFiles, setEnvFiles, selectedEnvFile, setSelectedEnvFile, diff --git a/frontend/src/components/EditorLayout/hooks/useStackActions.ts b/frontend/src/components/EditorLayout/hooks/useStackActions.ts index 686fef35..4bfdfab0 100644 --- a/frontend/src/components/EditorLayout/hooks/useStackActions.ts +++ b/frontend/src/components/EditorLayout/hooks/useStackActions.ts @@ -239,6 +239,7 @@ export function useStackActions(options: UseStackActionsOptions) { editorState.setEnvContent(''); editorState.setOriginalEnvContent(''); editorState.setEnvExists(false); + editorState.setEnvEtag(null); }; const loadEnvState = async (filename: string, signal?: AbortSignal) => { @@ -265,9 +266,11 @@ export function useStackActions(options: UseStackActionsOptions) { const envText = await envContentRes.text(); editorState.setEnvContent(envText || ''); editorState.setOriginalEnvContent(envText || ''); + editorState.setEnvEtag(envContentRes.headers.get('etag')); } else { editorState.setEnvContent(''); editorState.setOriginalEnvContent(''); + editorState.setEnvEtag(null); } } else { clearEnvState(); @@ -334,6 +337,7 @@ export function useStackActions(options: UseStackActionsOptions) { navState.setActiveView('editor'); editorState.setContent(text || ''); editorState.setOriginalContent(text || ''); + editorState.setComposeEtag(res.headers.get('etag')); await loadEnvState(filename, signal); await loadContainerState(filename, signal); await loadBackupState(filename, signal); @@ -343,8 +347,10 @@ export function useStackActions(options: UseStackActionsOptions) { stackListState.setSelectedFile(null); editorState.setContent(''); editorState.setOriginalContent(''); + editorState.setComposeEtag(null); editorState.setEnvContent(''); editorState.setOriginalEnvContent(''); + editorState.setEnvEtag(null); editorState.setContainers([]); } finally { if (!signal.aborted) { @@ -380,45 +386,81 @@ export function useStackActions(options: UseStackActionsOptions) { if (!res.ok) { editorState.setEnvContent(''); editorState.setOriginalEnvContent(''); + editorState.setEnvEtag(null); toast.error('Could not load env file'); return; } const text = await res.text(); editorState.setEnvContent(text || ''); editorState.setOriginalEnvContent(text || ''); + editorState.setEnvEtag(res.headers.get('etag')); } catch (e) { console.error('Failed to switch env file', e); editorState.setEnvContent(''); editorState.setOriginalEnvContent(''); + editorState.setEnvEtag(null); toast.error('Failed to load env file'); } finally { editorState.setIsFileLoading(false); } }; - const saveFile = async (): Promise => { + const saveFile = async (options?: { force?: boolean }): Promise => { if (editorState.activeTab === 'files') return false; if (!stackListState.selectedFile) return false; - const currentContent = - editorState.activeTab === 'compose' - ? editorState.content || '' - : editorState.envContent || ''; - const endpoint = - editorState.activeTab === 'compose' - ? `/stacks/${stackListState.selectedFile}` - : `/stacks/${stackListState.selectedFile}/env?file=${encodeURIComponent(editorState.selectedEnvFile)}`; + const force = options?.force === true; + const isCompose = editorState.activeTab === 'compose'; + const currentContent = isCompose + ? editorState.content || '' + : editorState.envContent || ''; + const endpoint = isCompose + ? `/stacks/${stackListState.selectedFile}` + : `/stacks/${stackListState.selectedFile}/env?file=${encodeURIComponent(editorState.selectedEnvFile)}`; + const etag = isCompose ? editorState.composeEtag : editorState.envEtag; + const headers: Record = {}; + if (!force && etag) headers['If-Match'] = etag; try { const response = await apiFetch(endpoint, { method: 'PUT', + headers, body: JSON.stringify({ content: currentContent }), }); + if (response.status === 412) { + const payload = await response.json().catch(() => null); + const currentRemoteContent = + payload && typeof payload.currentContent === 'string' ? payload.currentContent : ''; + const fileName = isCompose + ? 'compose.yaml' + : (editorState.selectedEnvFile || '.env').split('/').pop() ?? '.env'; + const confirmed = window.confirm( + `${fileName} was changed by another tab or process. Overwrite their changes with yours? Click Cancel to discard your local edits and reload the latest version.`, + ); + if (confirmed) { + return await saveFile({ force: true }); + } + if (isCompose) { + editorState.setContent(currentRemoteContent); + editorState.setOriginalContent(currentRemoteContent); + editorState.setComposeEtag(response.headers.get('etag')); + } else { + editorState.setEnvContent(currentRemoteContent); + editorState.setOriginalEnvContent(currentRemoteContent); + editorState.setEnvEtag(response.headers.get('etag')); + } + editorState.setIsEditing(false); + toast.success('Reloaded the latest version of the file.'); + return false; + } if (!response.ok) { throw new Error(`HTTP ${response.status}: ${await response.text()}`); } - if (editorState.activeTab === 'compose') { + const newEtag = response.headers.get('etag'); + if (isCompose) { editorState.setOriginalContent(editorState.content); + if (newEtag) editorState.setComposeEtag(newEtag); } else { editorState.setOriginalEnvContent(editorState.envContent); + if (newEtag) editorState.setEnvEtag(newEtag); } editorState.setIsEditing(false); toast.success('File saved successfully!');